diff --git a/README.md b/README.md index 5fdfa1c6..1ef51547 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,11 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin [F-Droid](https://f-droid.org/packages/ac.mdiq.podcini.R/) [Amazon](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13) -## Announcement - #### Podcini.R version 6.5 as a major step forward brings YouTube channels in the app. They can be searched, subscribed and played from within Podcini. For more see the changelogs #### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions. #### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions. -This project is based on a fork of [AntennaPod]() as of Feb 5 2024. +This project evolves from a fork of [AntennaPod]() as of Feb 5 2024. Compared to AntennaPod this project: diff --git a/app/build.gradle b/app/build.gradle index 6e77b238..1c512cb9 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 3020241 - versionName "6.5.7" + versionCode 3020242 + versionName "6.5.8" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt index 3dd6a50a..9be520eb 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt @@ -4,7 +4,7 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.PlayerStatus -import ac.mdiq.podcini.playback.service.LocalMediaPlayer +import ac.mdiq.podcini.playback.service.PlaybackService.LocalMediaPlayer import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.model.VolumeAdaptionSetting import androidx.test.annotation.UiThreadTest diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt index 45cfdb7d..c4ea6406 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt @@ -1,8 +1,8 @@ package de.test.podcini.service.playback import ac.mdiq.podcini.playback.base.PlayerStatus -import ac.mdiq.podcini.playback.service.TaskManager -import ac.mdiq.podcini.playback.service.TaskManager.PSTMCallback +import ac.mdiq.podcini.playback.service.PlaybackService.TaskManager +import ac.mdiq.podcini.playback.service.PlaybackService.TaskManager.PSTMCallback import ac.mdiq.podcini.preferences.SleepTimerPreferences.setShakeToReset import ac.mdiq.podcini.preferences.SleepTimerPreferences.setVibrate import ac.mdiq.podcini.storage.model.Feed diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 478cbbe8..38ae72ec 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -54,7 +54,6 @@ android:enabled="true" android:exported="true" tools:ignore="ExportedService"> - diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt index e4744c69..60383b80 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt @@ -6,7 +6,7 @@ import ac.mdiq.podcini.net.download.service.DefaultDownloaderFactory import ac.mdiq.podcini.net.download.service.DownloadRequestCreator.create import ac.mdiq.podcini.net.download.service.DownloadRequest import ac.mdiq.podcini.net.feed.parser.FeedHandler -import ac.mdiq.podcini.net.feed.parser.FeedHandlerResult +import ac.mdiq.podcini.net.feed.parser.FeedHandler.FeedHandlerResult import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileFeedRefresh import ac.mdiq.podcini.net.utils.NetworkUtils.isFeedRefreshAllowed import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt index 16b5765e..cdb2b38c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt @@ -63,7 +63,6 @@ class FeedHandler { Logd(TAG, "Recognized type Atom") val strLang = xpp.getAttributeValue("http://www.w3.org/XML/1998/namespace", "lang") if (strLang != null) feed.language = strLang - return Type.ATOM } RSS_ROOT -> { @@ -113,8 +112,7 @@ class FeedHandler { e.printStackTrace() } finally { if (reader != null) { - try { - reader.close() + try { reader.close() } catch (e: IOException) { Logd(TAG, "IOException: $reader") e.printStackTrace() @@ -130,8 +128,7 @@ class FeedHandler { if (feed.fileUrl == null) return null val reader: Reader - try { - reader = XmlStreamReader(File(feed.fileUrl!!)) + try { reader = XmlStreamReader(File(feed.fileUrl!!)) } catch (e: FileNotFoundException) { Logd(TAG, "FileNotFoundException: " + feed.fileUrl) e.printStackTrace() @@ -218,10 +215,6 @@ class FeedHandler { state.namespaces[uri] = Itunes() Logd(TAG, "Recognized ITunes namespace") } -// uri == YouTube.NSURI && prefix == YouTube.NSTAG -> { -// state.namespaces[uri] = YouTube() -// Logd(TAG, "Recognized YouTube namespace") -// } uri == SimpleChapters.NSURI && prefix.matches(SimpleChapters.NSTAG.toRegex()) -> { state.namespaces[uri] = SimpleChapters() Logd(TAG, "Recognized SimpleChapters namespace") @@ -289,21 +282,16 @@ class FeedHandler { type = Type.INVALID } -// fun getMessage(): String? { -// return if (message != null) { -// message!! -// } else if (type == TypeGetter.Type.INVALID) { -// "Invalid type" -// } else { -// "Type $type not supported" -// } -// } - companion object { private const val serialVersionUID = 9105878964928170669L } } + class FeedHandlerResult( + @JvmField val feed: Feed, + @JvmField val alternateFeedUrls: Map, + val redirectUrl: String) + companion object { private val TAG: String = FeedHandler::class.simpleName ?: "Anonymous" private const val ATOM_ROOT = "feed" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt deleted file mode 100644 index 0e3ba9e6..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt +++ /dev/null @@ -1,70 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import android.util.Log -import androidx.core.text.HtmlCompat -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.utils.DurationParser.inMillis -import org.xml.sax.Attributes - -// TODO: this appears not needed -class YouTube : Namespace() { - val TAG = this::class.simpleName ?: "Anonymous" - - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { -// Logd(TAG, "handleElementStart $localName") - if (IMAGE == localName) { - val url: String? = attributes.getValue(IMAGE_HREF) - if (state.currentItem != null) state.currentItem!!.imageUrl = url - // this is the feed image, prefer to all other images - else if (!url.isNullOrEmpty()) state.feed.imageUrl = url - } - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { -// Logd(TAG, "handleElementEnd $localName") - if (state.contentBuf == null) return - - val content = state.contentBuf.toString() - val contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - if (content.isEmpty()) return - - when { - AUTHOR == localName && state.tagstack.size <= 3 -> state.feed.author = contentFromHtml - DURATION == localName -> { - try { - val durationMs = inMillis(content) - state.tempObjects[DURATION] = durationMs.toInt() - } catch (e: NumberFormatException) { Log.e(NSTAG, String.format("Duration '%s' could not be parsed", content)) } - } - SUBTITLE == localName -> { - when { - state.currentItem != null && state.currentItem?.description.isNullOrEmpty() -> state.currentItem!!.setDescriptionIfLonger(content) - state.feed.description.isNullOrEmpty() -> state.feed.description = content - } - } - SUMMARY == localName -> { - when { - state.currentItem != null -> state.currentItem!!.setDescriptionIfLonger(content) - Rss20.CHANNEL == state.secondTag.name -> state.feed.description = content - } - } - NEW_FEED_URL == localName && content.trim { it <= ' ' }.startsWith("http") -> state.redirectUrl = content.trim { it <= ' ' } - } - } - - companion object { - const val NSTAG: String = "yt" - const val NSURI: String = "http://www.youtube.com/xml/schemas/2015" - - private const val IMAGE = "thumbnail" - private const val IMAGE_HREF = "href" - - private const val AUTHOR = "author" - const val DURATION: String = "duration" - private const val SUBTITLE = "subtitle" - private const val SUMMARY = "summary" - private const val NEW_FEED_URL = "new-feed-url" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt index 559d871a..5ff8d6f4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt @@ -31,7 +31,7 @@ object MimeTypeUtils { fun isMediaFile(type: String?): Boolean { return if (type == null) false else type.startsWith("audio/") || type.startsWith("video/") || type == "application/ogg" - || type == "application/octet-stream" || type == "application/x-shockwave-flash" + || type == "application/octet-stream" } @JvmStatic diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt index 53f13ac4..3483229e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt @@ -19,6 +19,13 @@ class PlaybackServiceStarter(private val context: Context, private val media: Pl private var shouldStreamThisTime = false private var callEvenIfRunning = false + val intent: Intent + get() { + val launchIntent = Intent(context, PlaybackService::class.java) +// launchIntent.putExtra(PlaybackServiceConstants.EXTRA_PLAYABLE, media as Parcelable) + launchIntent.putExtra(EXTRA_ALLOW_STREAM_THIS_TIME, shouldStreamThisTime) + return launchIntent + } /** * Default value: false @@ -33,14 +40,6 @@ class PlaybackServiceStarter(private val context: Context, private val media: Pl return this } - val intent: Intent - get() { - val launchIntent = Intent(context, PlaybackService::class.java) -// launchIntent.putExtra(PlaybackServiceConstants.EXTRA_PLAYABLE, media as Parcelable) - launchIntent.putExtra(EXTRA_ALLOW_STREAM_THIS_TIME, shouldStreamThisTime) - return launchIntent - } - fun start() { Logd("PlaybackServiceStarter", "starting PlaybackService") if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END)) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt index 283573a7..1257a49e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt @@ -1,11 +1,11 @@ package ac.mdiq.podcini.playback.base +//import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curState -import ac.mdiq.podcini.preferences.UserPreferences +import ac.mdiq.podcini.preferences.UserPreferences.Prefs import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.setPlaybackSpeed -import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.storage.model.MediaType @@ -311,7 +311,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont val audioPlaybackSpeed: Float get() { - try { return appPrefs.getString(UserPreferences.Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat() + try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat() } catch (e: NumberFormatException) { Log.e(TAG, Log.getStackTraceString(e)) setPlaybackSpeed(1.0f) @@ -367,7 +367,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont @JvmStatic fun getCurrentPlaybackSpeed(media: Playable?): Float { var playbackSpeed = FeedPreferences.SPEED_USE_GLOBAL - val mediaType: MediaType? = media?.getMediaType() if (media != null) { playbackSpeed = curState.curTempSpeed if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && media is EpisodeMedia) { @@ -375,12 +374,8 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont if (prefs_ != null) playbackSpeed = prefs_.playSpeed } } - if (mediaType != null && playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = getPlaybackSpeed(mediaType) + if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = audioPlaybackSpeed return playbackSpeed } - - fun getPlaybackSpeed(mediaType: MediaType): Float { - return if (mediaType == MediaType.VIDEO) videoPlaybackSpeed else audioPlaybackSpeed - } } } 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 deleted file mode 100644 index 4d6da2b3..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt +++ /dev/null @@ -1,837 +0,0 @@ -package ac.mdiq.podcini.playback.service - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder -import ac.mdiq.podcini.net.download.service.PodciniHttpClient -import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted -import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked -import ac.mdiq.podcini.playback.base.InTheatre.curEpisode -import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue -import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.base.InTheatre.curQueue -import ac.mdiq.podcini.playback.base.MediaPlayerBase -import ac.mdiq.podcini.playback.base.MediaPlayerCallback -import ac.mdiq.podcini.playback.base.PlayerStatus -import ac.mdiq.podcini.playback.base.VideoMode -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope -import ac.mdiq.podcini.storage.model.* -import ac.mdiq.podcini.storage.utils.EpisodeUtil -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.config.ClientConfig -import ac.mdiq.vista.extractor.MediaFormat -import ac.mdiq.vista.extractor.Vista -import ac.mdiq.vista.extractor.stream.* -import android.app.UiModeManager -import android.content.Context -import android.content.res.Configuration -import android.media.audiofx.LoudnessEnhancer -import android.net.Uri -import android.util.Log -import android.util.Pair -import android.view.SurfaceHolder -import androidx.core.util.Consumer -import androidx.media3.common.* -import androidx.media3.common.Player.* -import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.HttpDataSource.HttpDataSourceException -import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.media3.exoplayer.DefaultLoadControl -import androidx.media3.exoplayer.DefaultRenderersFactory -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.SeekParameters -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.MergingMediaSource -import androidx.media3.exoplayer.source.ProgressiveMediaSource -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride -import androidx.media3.exoplayer.trackselection.ExoTrackSelection -import androidx.media3.extractor.DefaultExtractorsFactory -import androidx.media3.extractor.mp3.Mp3Extractor -import androidx.media3.ui.DefaultTrackNameProvider -import androidx.media3.ui.TrackNameProvider -import kotlinx.coroutines.* -import java.io.IOException -import java.lang.Runnable -import java.util.* -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.concurrent.Volatile - -/** - * Manages the MediaPlayer object of the PlaybackService. - */ -@UnstableApi -class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) { - - @Volatile - private var statusBeforeSeeking: PlayerStatus? = null - - @Volatile - private var videoSize: Pair? = null - private var isShutDown = false - private var seekLatch: CountDownLatch? = null - - private val bufferUpdateInterval = 5000L - private var mediaSource: MediaSource? = null - private var mediaItem: MediaItem? = null - private var playbackParameters: PlaybackParameters - - private var bufferedPercentagePrev = 0 - - private val formats: List - get() { - val formats_: MutableList = arrayListOf() - val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList() - val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) - for (i in 0 until trackGroups.length) { - formats_.add(trackGroups[i].getFormat(0)) - } - return formats_ - } - - private val audioRendererIndex: Int - get() { - for (i in 0 until exoPlayer!!.rendererCount) { - if (exoPlayer?.getRendererType(i) == C.TRACK_TYPE_AUDIO) return i - } - return -1 - } - - private val videoWidth: Int - get() { - return exoPlayer?.videoFormat?.width ?: 0 - } - - private val videoHeight: Int - get() { - return exoPlayer?.videoFormat?.height ?: 0 - } - - init { - if (httpDataSourceFactory == null) { - runOnIOScope { - httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) - .setUserAgent(ClientConfig.USER_AGENT) - } - } - if (exoPlayer == null) { - setupPlayerListener() - createStaticPlayer(context) - } - playbackParameters = exoPlayer!!.playbackParameters - val scope = CoroutineScope(Dispatchers.Main) - scope.launch { - while (true) { - delay(bufferUpdateInterval) - withContext(Dispatchers.Main) { - if (exoPlayer != null && bufferedPercentagePrev != exoPlayer!!.bufferedPercentage) { - bufferingUpdateListener?.accept(exoPlayer!!.bufferedPercentage) - bufferedPercentagePrev = exoPlayer!!.bufferedPercentage - } - } - } - } - } - - @Throws(IllegalStateException::class) - private fun prepareWR() { - Logd(TAG, "prepareWR() called") - if (mediaSource == null && mediaItem == null) return - if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false) - else exoPlayer?.setMediaItem(mediaItem!!) - exoPlayer?.prepare() - } - - private fun release() { - Logd(TAG, "release() called") - exoPlayer?.stop() - exoPlayer?.seekTo(0L) - audioSeekCompleteListener = null - audioCompletionListener = null - audioErrorListener = null - bufferingUpdateListener = null - } - - private fun setAudioStreamType(i: Int) { - val a = exoPlayer!!.audioAttributes - val b = AudioAttributes.Builder() - b.setContentType(i) - b.setFlags(a.flags) - b.setUsage(a.usage) - exoPlayer?.setAudioAttributes(b.build(), true) - } - - @Throws(IllegalArgumentException::class, IllegalStateException::class) - private fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) { - Logd(TAG, "setDataSource: $mediaUrl") - mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build() - mediaSource = null - setSourceCredentials(user, password) - } - - @Throws(IllegalArgumentException::class, IllegalStateException::class) - private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) { - Logd(TAG, "setDataSource1 called") - val url = media.getStreamUrl() ?: return - val preferences = media.episodeOrFetch()?.feed?.preferences - val user = preferences?.username - val password = preferences?.password - if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) { - Logd(TAG, "setDataSource1 setting for YouTube source") - try { - val vService = Vista.getService(0) - val streamInfo = StreamInfo.getInfo(vService, url) - val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams) - Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}") - val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1 - val audioStream = audioStreamsList[audioIndex] - Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}") - val aSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri(Uri.parse(audioStream.content)).build()) - if (media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { - Logd(TAG, "setDataSource1 result: $streamInfo") - Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}") - val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true) - val videoIndex = 0 - val videoStream = videoStreamsList[videoIndex] - Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}") - val vSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri(Uri.parse(videoStream.content)).build()) - val mediaSources: MutableList = ArrayList() - mediaSources.add(vSource) - mediaSources.add(aSource) - mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray()) -// mediaSource = null - } else mediaSource = aSource - mediaItem = mediaSource?.mediaItem - setSourceCredentials(user, password) - } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") } - } else { - Logd(TAG, "setDataSource1 setting for Podcast source") - setDataSource(metadata, url,user, password) - } - } - - private fun setSourceCredentials(user: String?, password: String?) { - if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) { - if (httpDataSourceFactory == null) - httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) - .setUserAgent(ClientConfig.USER_AGENT) - - val requestProperties = HashMap() - requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1") - httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties) - - val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!) - val extractorsFactory = DefaultExtractorsFactory() - extractorsFactory.setConstantBitrateSeekingEnabled(true) - extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA) - val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) - - mediaSource = f.createMediaSource(mediaItem!!) - } - } - - /** - * Join the two lists of video streams (video_only and normal videos), - * and sort them according with default format chosen by the user. - * - * @param defaultFormat format to give preference - * @param showHigherResolutions show >1080p resolutions - * @param videoStreams normal videos list - * @param videoOnlyStreams video only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only - * streams and normal video streams are available - * @return the sorted list - */ - private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean, - preferVideoOnlyStreams: Boolean): List { - val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams) - val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList() - val comparator = compareBy { it.resolution.toResolutionValue() } - return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) } - } - - private fun String.toResolutionValue(): Int { - val match = Regex("(\\d+)p|(\\d+)k").find(this) - return when { - match?.groupValues?.get(1) != null -> match.groupValues[1].toInt() - match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024 - else -> 0 - } - } - - private fun getFilteredAudioStreams(audioStreams: List?): List { - if (audioStreams == null) return listOf() - val collectedStreams = mutableSetOf() - for (stream in audioStreams) { - Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}") - if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS)) - continue - collectedStreams.add(stream) - } - return collectedStreams.toList().sortedWith(compareBy { it.bitrate }) - } - - /** - * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing - * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will - * not do anything. - * Whether playback starts immediately depends on the given parameters. See below for more details. - * States: - * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. - * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If - * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. - * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object - * will enter the ERROR state. - * This method is executed on an internal executor service. - * @param playable The Playable object that is supposed to be played. This parameter must not be null. - * @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via - * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by - * the Android MediaPlayer via getStreamUrl. - * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the - * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared - * for playback immediately (see 'prepareImmediately' parameter for more details) - * @param prepareImmediately Set to true if the method should also prepare the episode for playback. - */ - override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { - Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ") -// showStackTrace() - if (curMedia != null) { - Logd(TAG, "playMediaObject: curMedia exist status=$status") - if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) { - Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.") - return - } - Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}") - // set temporarily to pause in order to update list with current position - if (status == PlayerStatus.PLAYING) { - val pos = curMedia?.getPosition() ?: -1 - seekTo(pos) - callback.onPlaybackPause(curMedia, pos) - } - // stop playback of this episode - if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop() -// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) -// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true) - setPlayerStatus(PlayerStatus.INDETERMINATE, null) - } - - Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}") - curMedia = playable - if (curMedia is EpisodeMedia) { - val media_ = curMedia as EpisodeMedia - val item = media_.episodeOrFetch() - val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf() - curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id) - } else curIndexInQueue = -1 - - prevMedia = curMedia - this.isStreaming = streaming - mediaType = curMedia!!.getMediaType() - videoSize = null - createMediaPlayer() - this.startWhenPrepared.set(startWhenPrepared) - setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) - val metadata = buildMetadata(curMedia!!) - try { - callback.ensureMediaInfoLoaded(curMedia!!) - callback.onMediaChanged(false) - setPlaybackParams(getCurrentPlaybackSpeed(curMedia), UserPreferences.isSkipSilence) - CoroutineScope(Dispatchers.IO).launch { - when { - streaming -> { - val streamurl = curMedia!!.getStreamUrl() - if (streamurl != null) { - val media = curMedia - if (media is EpisodeMedia) { - mediaItem = null - mediaSource = null - setDataSource(metadata, media) -// 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) - } - } - else -> { - val localMediaurl = curMedia!!.getLocalMediaUrl() -// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle -// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null) - if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) - else throw IOException("Unable to read local file $localMediaurl") - } - } - withContext(Dispatchers.Main) { - val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager - if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) - if (prepareImmediately) prepare() - } - } - } catch (e: IOException) { - e.printStackTrace() - setPlayerStatus(PlayerStatus.ERROR, null) - EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) - } catch (e: IllegalStateException) { - e.printStackTrace() - setPlayerStatus(PlayerStatus.ERROR, null) - EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) - } finally { } - } - - override fun resume() { - if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { - Logd(TAG, "Resuming/Starting playback") - acquireWifiLockIfNecessary() - setPlaybackParams(getCurrentPlaybackSpeed(curMedia), UserPreferences.isSkipSilence) - setVolume(1.0f, 1.0f) - - if (curMedia != null && status == PlayerStatus.PREPARED && curMedia!!.getPosition() > 0) { - val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime()) - seekTo(newPosition) - } - if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR() -// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) } - exoPlayer?.play() - // Can't set params when paused - so always set it on start in case they changed - exoPlayer?.playbackParameters = playbackParameters - setPlayerStatus(PlayerStatus.PLAYING, curMedia) - if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!)) - } else Logd(TAG, "Call to resume() was ignored because current state of PSMP object is $status") - } - - override fun pause(abandonFocus: Boolean, reinit: Boolean) { - releaseWifiLockIfNecessary() - if (status == PlayerStatus.PLAYING) { - Logd(TAG, "Pausing playback $abandonFocus $reinit") - exoPlayer?.pause() - setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition()) - if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END)) - if (isStreaming && reinit) reinit() - } else Logd(TAG, "Ignoring call to pause: Player is in $status state") - } - - override fun prepare() { - if (status == PlayerStatus.INITIALIZED) { - Logd(TAG, "Preparing media player") - setPlayerStatus(PlayerStatus.PREPARING, curMedia) - prepareWR() -// onPrepared(startWhenPrepared.get()) - if (mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight) - if (curMedia != null) { - val pos = curMedia!!.getPosition() - if (pos > 0) seekTo(pos) - if (curMedia != null && curMedia!!.getDuration() <= 0) { - Logd(TAG, "Setting duration of media") - curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt()) - } - } - setPlayerStatus(PlayerStatus.PREPARED, curMedia) - if (startWhenPrepared.get()) resume() - } - } - - override fun reinit() { - Logd(TAG, "reinit() called") - releaseWifiLockIfNecessary() - when { - curMedia != null -> playMediaObject(curMedia!!, isStreaming, startWhenPrepared.get(), prepareImmediately = false, true) - else -> Logd(TAG, "Call to reinit: media and mediaPlayer were null, ignored") - } - } - - override fun seekTo(t: Int) { - var t = t - if (t < 0) t = 0 - Logd(TAG, "seekTo() called $t") - - if (t >= getDuration()) { - Logd(TAG, "Seek reached end of file, skipping to next episode") - exoPlayer?.seekTo(t.toLong()) // can set curMedia to null - if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration())) - audioSeekCompleteListener?.run() - endPlayback(true, wasSkipped = true, true, toStoppedState = true) - t = getPosition() -// return - } - - when (status) { - PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { - Logd(TAG, "seekTo() called $t") - if (seekLatch != null && seekLatch!!.count > 0) { - try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) } - } - seekLatch = CountDownLatch(1) - statusBeforeSeeking = status - setPlayerStatus(PlayerStatus.SEEKING, curMedia, t) - exoPlayer?.seekTo(t.toLong()) - if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration())) - audioSeekCompleteListener?.run() - if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t) - try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) } - } - PlayerStatus.INITIALIZED -> { - curMedia?.setPosition(t) - startWhenPrepared.set(false) - prepare() - } - else -> {} - } - } - - override fun getDuration(): Int { - return curMedia?.getDuration() ?: Playable.INVALID_TIME - } - - override fun getPosition(): Int { - var retVal = Playable.INVALID_TIME - if (exoPlayer != null && status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt() - if (retVal <= 0 && curMedia != null) retVal = curMedia!!.getPosition() - return retVal - } - - override fun setPlaybackParams(speed: Float, skipSilence: Boolean) { - EventFlow.postEvent(FlowEvent.SpeedChangedEvent(speed)) - Logd(TAG, "setPlaybackParams speed=$speed pitch=${playbackParameters.pitch} skipSilence=$skipSilence") - playbackParameters = PlaybackParameters(speed, playbackParameters.pitch) - exoPlayer!!.skipSilenceEnabled = skipSilence - exoPlayer!!.playbackParameters = playbackParameters - } - - override fun getPlaybackSpeed(): Float { - var retVal = 1f - if (status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED || status == PlayerStatus.INITIALIZED || status == PlayerStatus.PREPARED) - retVal = playbackParameters.speed - return retVal - } - - override fun setVolume(volumeLeft: Float, volumeRight: Float) { - var volumeLeft = volumeLeft - var volumeRight = volumeRight - val playable = curMedia - if (playable is EpisodeMedia) { - val preferences = playable.episodeOrFetch()?.feed?.preferences - if (preferences != null) { - val volumeAdaptionSetting = preferences.volumeAdaptionSetting - if (volumeAdaptionSetting != null) { - val adaptionFactor = volumeAdaptionSetting.adaptionFactor - volumeLeft *= adaptionFactor - volumeRight *= adaptionFactor - } - } - } - if (volumeLeft > 1) { - exoPlayer?.volume = 1f - loudnessEnhancer?.setEnabled(true) - loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt()) - } else { - exoPlayer?.volume = volumeLeft - loudnessEnhancer?.setEnabled(false) - } - Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight") - } - - override fun shutdown() { - Logd(TAG, "shutdown() called") - try { - clearMediaPlayerListeners() -// TODO: should use: exoPlayer!!.playWhenReady ? - if (exoPlayer?.isPlaying == true) exoPlayer?.stop() - } catch (e: Exception) { - e.printStackTrace() - } - release() - status = PlayerStatus.STOPPED - - isShutDown = true - releaseWifiLockIfNecessary() - } - - override fun setVideoSurface(surface: SurfaceHolder?) { - exoPlayer?.setVideoSurfaceHolder(surface) - } - - override fun resetVideoSurface() { - if (mediaType == MediaType.VIDEO) { - Logd(TAG, "Resetting video surface") - exoPlayer?.setVideoSurfaceHolder(null) - reinit() - } else Log.e(TAG, "Resetting video surface for media of Audio type") - } - - /** - * Return width and height of the currently playing video as a pair. - * @return Width and height as a Pair or null if the video size could not be determined. The method might still - * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return - * invalid values. - */ - override fun getVideoSize(): Pair? { - if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight) - return videoSize - } - - override fun getAudioTracks(): List { - val trackNames: MutableList = ArrayList() - val trackNameProvider: TrackNameProvider = DefaultTrackNameProvider(context.resources) - for (format in formats) { - trackNames.add(trackNameProvider.getTrackName(format)) - } - return trackNames - } - - override fun setAudioTrack(track: Int) { - val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return - val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) - val override = SelectionOverride(track, 0) - val rendererIndex = audioRendererIndex - val params = trackSelector!!.buildUponParameters().setSelectionOverride(rendererIndex, trackGroups, override) - trackSelector!!.setParameters(params) - } - - override fun getSelectedAudioTrack(): Int { - val trackSelections = exoPlayer!!.currentTrackSelections - val availableFormats = formats - Logd(TAG, "selectedAudioTrack called tracks: ${trackSelections.length} formats: ${availableFormats.size}") - for (i in 0 until trackSelections.length) { - val track = trackSelections[i] as? ExoTrackSelection ?: continue - if (availableFormats.contains(track.selectedFormat)) return availableFormats.indexOf(track.selectedFormat) - } - return -1 - } - - override fun createMediaPlayer() { - Logd(TAG, "createMediaPlayer()") - release() - if (curMedia == null) { - status = PlayerStatus.STOPPED - return - } - setAudioStreamType(C.AUDIO_CONTENT_TYPE_SPEECH) - setMediaPlayerListeners() - } - - override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean, toStoppedState: Boolean) { - releaseWifiLockIfNecessary() - if (curMedia == null) return - - val isPlaying = status == PlayerStatus.PLAYING - // we're relying on the position stored in the Playable object for post-playback processing - val position = getPosition() - if (position >= 0) curMedia?.setPosition(position) - Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState") - - val currentMedia = curMedia - var nextMedia: Playable? = null - if (shouldContinue) { - // Load next episode if previous episode was in the queue and if there is an episode in the queue left. - // Start playback immediately if continuous playback is enabled - nextMedia = callback.getNextInQueue(currentMedia) - if (nextMedia != null) { - Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false") - if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null) - callback.onPlaybackEnded(nextMedia.getMediaType(), false) - // setting media to null signals to playMediaObject that we're taking care of post-playback processing - curMedia = null - if(nextMedia != null) playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying) - } - } - when { - shouldContinue || toStoppedState -> { - if (nextMedia == null) { - Logd(TAG, "nextMedia is null. call callback.onPlaybackEnded true") - callback.onPlaybackEnded(null, true) - curMedia = null - exoPlayer?.stop() - releaseWifiLockIfNecessary() - if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null) - else Logd(TAG, "Ignored call to stop: Current player state is: $status") - } - val hasNext = nextMedia != null - if (currentMedia != null) callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext) -// curMedia = nextMedia - } - isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia!!.getPosition()) - } - } - - override fun shouldLockWifi(): Boolean { - return isStreaming - } - - private fun setMediaPlayerListeners() { - if (curMedia == null) return - - audioCompletionListener = Runnable { - Logd(TAG, "audioCompletionListener called") - endPlayback(hasEnded = true, wasSkipped = false, shouldContinue = true, toStoppedState = true) - } - audioSeekCompleteListener = Runnable { this.genericSeekCompleteListener() } - bufferingUpdateListener = Consumer { percent: Int -> - when (percent) { - BUFFERING_STARTED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.started()) - BUFFERING_ENDED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.ended()) - else -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.progressUpdate(0.01f * percent)) - } - } - audioErrorListener = Consumer { message: String -> - Log.e(TAG, "PlayerErrorEvent: $message") - EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(message)) - } - } - - private fun clearMediaPlayerListeners() { - audioCompletionListener = Runnable {} - audioSeekCompleteListener = Runnable {} - bufferingUpdateListener = Consumer { } - audioErrorListener = Consumer {} - } - - private fun genericSeekCompleteListener() { - Logd(TAG, "genericSeekCompleteListener $status ${exoPlayer?.isPlaying} $statusBeforeSeeking") - seekLatch?.countDown() - - if ((status == PlayerStatus.PLAYING || exoPlayer?.isPlaying != true) && curMedia != null) callback.onPlaybackStart(curMedia!!, getPosition()) - if (status == PlayerStatus.SEEKING && statusBeforeSeeking != null) setPlayerStatus(statusBeforeSeeking!!, curMedia, getPosition()) - } - - override fun isCasting(): Boolean { - return false - } - - private fun setupPlayerListener() { - exoplayerListener = object : Listener { - override fun onPlaybackStateChanged(playbackState: @State Int) { - Logd(TAG, "onPlaybackStateChanged $playbackState") - when (playbackState) { - STATE_ENDED -> { - exoPlayer?.seekTo(C.TIME_UNSET) - audioCompletionListener?.run() - } - STATE_BUFFERING -> bufferingUpdateListener?.accept(BUFFERING_STARTED) - else -> bufferingUpdateListener?.accept(BUFFERING_ENDED) - } - } - override fun onIsPlayingChanged(isPlaying: Boolean) { - val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED - setPlayerStatus(stat, curMedia) - Logd(TAG, "onIsPlayingChanged $isPlaying") - } - override fun onPlayerError(error: PlaybackException) { - Logd(TAG, "onPlayerError ${error.message}") - if (wasDownloadBlocked(error)) audioErrorListener?.accept(context.getString(R.string.download_error_blocked)) - else { - var cause = error.cause - if (cause is HttpDataSourceException && cause.cause != null) cause = cause.cause - if (cause != null && "Source error" == cause.message) cause = cause.cause - audioErrorListener?.accept((if (cause != null) cause.message else error.message) ?:"no message") - } - } - override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, reason: @DiscontinuityReason Int) { - Logd(TAG, "onPositionDiscontinuity $oldPosition $newPosition $reason") - if (reason == DISCONTINUITY_REASON_SEEK) audioSeekCompleteListener?.run() - } - override fun onAudioSessionIdChanged(audioSessionId: Int) { - Logd(TAG, "onAudioSessionIdChanged $audioSessionId") - initLoudnessEnhancer(audioSessionId) - } - } - } - - companion object { - private val TAG: String = LocalMediaPlayer::class.simpleName ?: "Anonymous" - - const val BUFFERING_STARTED: Int = -1 - const val BUFFERING_ENDED: Int = -2 - - private var httpDataSourceFactory: OkHttpDataSource.Factory? = null - - private var trackSelector: DefaultTrackSelector? = null - - var exoPlayer: ExoPlayer? = null - - private var exoplayerListener: Listener? = null - private var audioSeekCompleteListener: Runnable? = null - private var audioCompletionListener: Runnable? = null - private var audioErrorListener: Consumer? = null - private var bufferingUpdateListener: Consumer? = null - private var loudnessEnhancer: LoudnessEnhancer? = null - - fun createStaticPlayer(context: Context) { - val loadControl = DefaultLoadControl.Builder() - loadControl.setBufferDurationsMs(30000, 120000, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) - loadControl.setBackBuffer(UserPreferences.rewindSecs * 1000 + 500, true) - trackSelector = DefaultTrackSelector(context) - val audioOffloadPreferences = AudioOffloadPreferences.Builder() - .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) // Add additional options as needed - .setIsGaplessSupportRequired(true) - .setIsSpeedChangeSupportRequired(true) - .build() - Logd(TAG, "createStaticPlayer creating exoPlayer_") - - val defaultRenderersFactory = DefaultRenderersFactory(context) -// defaultRenderersFactory.setMediaCodecSelector { mimeType: String?, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean -> -// val decoderInfos: List = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder) -// val result: MutableList = ArrayList() -// for (decoderInfo in decoderInfos) { -// Logd(TAG, "decoderInfo.name: ${decoderInfo.name}") -// if (decoderInfo.name == "c2.android.mp3.decoder") { -// continue -// } -// result.add(decoderInfo) -// } -// result -// } - exoPlayer = ExoPlayer.Builder(context, defaultRenderersFactory) - .setTrackSelector(trackSelector!!) - .setLoadControl(loadControl.build()) - .build() - - exoPlayer?.setSeekParameters(SeekParameters.EXACT) - exoPlayer!!.trackSelectionParameters = exoPlayer!!.trackSelectionParameters - .buildUpon() - .setAudioOffloadPreferences(audioOffloadPreferences) - .build() - -// if (BuildConfig.DEBUG) exoPlayer!!.addAnalyticsListener(EventLogger()) - - if (exoplayerListener != null) { - exoPlayer?.removeListener(exoplayerListener!!) - exoPlayer?.addListener(exoplayerListener!!) - } - initLoudnessEnhancer(exoPlayer!!.audioSessionId) - } - - private fun initLoudnessEnhancer(audioStreamId: Int) { - 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 - } - } - - fun cleanup() { - if (exoplayerListener != null) exoPlayer?.removeListener(exoplayerListener!!) - exoplayerListener = null - audioSeekCompleteListener = null - audioCompletionListener = null - audioErrorListener = null - bufferingUpdateListener = null - loudnessEnhancer = null - httpDataSourceFactory = null - } - } -} 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 52c1f420..9a5a0361 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 @@ -1,11 +1,15 @@ package ac.mdiq.podcini.playback.service import ac.mdiq.podcini.R +import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder +import ac.mdiq.podcini.net.download.service.PodciniHttpClient import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileStreaming +import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed +import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked import ac.mdiq.podcini.playback.PlaybackServiceStarter -import ac.mdiq.podcini.playback.base.InTheatre +import ac.mdiq.podcini.playback.base.* import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue import ac.mdiq.podcini.playback.base.InTheatre.curMedia @@ -13,15 +17,12 @@ import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.playback.base.InTheatre.loadPlayableFromPreferences import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying -import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.buildMediaItem import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo -import ac.mdiq.podcini.playback.base.MediaPlayerCallback -import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.cast.CastPsmp import ac.mdiq.podcini.playback.cast.CastStateListener -import ac.mdiq.podcini.playback.service.TaskManager.PSTMCallback +import ac.mdiq.podcini.preferences.SleepTimerPreferences import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableFrom import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableTo @@ -50,53 +51,96 @@ import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_OTHER import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_PAUSED import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_PLAYING import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction +import ac.mdiq.podcini.storage.utils.ChapterUtils import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.ui.utils.NotificationUtils +import ac.mdiq.podcini.ui.widget.WidgetUpdater import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.config.ClientConfig +import ac.mdiq.vista.extractor.MediaFormat import ac.mdiq.vista.extractor.Vista +import ac.mdiq.vista.extractor.stream.AudioStream +import ac.mdiq.vista.extractor.stream.DeliveryMethod import ac.mdiq.vista.extractor.stream.StreamInfo +import ac.mdiq.vista.extractor.stream.VideoStream import android.annotation.SuppressLint import android.app.NotificationManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.app.UiModeManager import android.bluetooth.BluetoothA2dp import android.content.* import android.content.Intent.EXTRA_KEY_EVENT +import android.content.res.Configuration +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.net.Uri import android.os.* import android.os.Build.VERSION_CODES import android.service.quicksettings.TileService import android.util.Log +import android.util.Pair import android.view.KeyEvent import android.view.KeyEvent.KEYCODE_MEDIA_STOP +import android.view.SurfaceHolder import android.view.ViewConfiguration import android.webkit.URLUtil import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationCompat -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player.STATE_ENDED -import androidx.media3.common.Player.STATE_IDLE +import androidx.core.util.Consumer +import androidx.media3.common.* +import androidx.media3.common.Player.* +import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.HttpDataSource.HttpDataSourceException +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride +import androidx.media3.exoplayer.trackselection.ExoTrackSelection +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.mp3.Mp3Extractor import androidx.media3.session.* +import androidx.media3.ui.DefaultTrackNameProvider +import androidx.media3.ui.TrackNameProvider import androidx.work.impl.utils.futures.SettableFuture import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest +import java.io.IOException import java.util.* +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit import kotlin.concurrent.Volatile import kotlin.math.max +import kotlin.math.sqrt /** * Controls the MediaPlayer that plays a EpisodeMedia-file @@ -213,7 +257,7 @@ class PlaybackService : MediaLibraryService() { } } - private val taskManagerCallback: PSTMCallback = object : PSTMCallback { + private val taskManagerCallback: TaskManager.PSTMCallback = object : TaskManager.PSTMCallback { override fun positionSaverTick() { if (curPosition != prevPosition) { // Log.d(TAG, "positionSaverTick currentPosition: $currentPosition, currentPlaybackSpeed: $currentPlaybackSpeed") @@ -404,6 +448,7 @@ class PlaybackService : MediaLibraryService() { writeNoMediaPlaying() return null } + EventFlow.postEvent(FlowEvent.PlayEvent(item, Action.END)) if (curIndexInQueue < 0 && item.feed?.preferences?.queue != null) { Logd(TAG, "getNextInQueue(), curMedia is not in curQueue") writeNoMediaPlaying() @@ -441,8 +486,7 @@ class PlaybackService : MediaLibraryService() { writeNoMediaPlaying() return null } - EventFlow.postEvent(FlowEvent.PlayEvent(item, FlowEvent.PlayEvent.Action.END)) - EventFlow.postEvent(FlowEvent.PlayEvent(nextItem)) +// EventFlow.postEvent(FlowEvent.PlayEvent(nextItem)) return if (nextItem.media == null) null else unmanaged(nextItem.media!!) } // only used in test @@ -630,7 +674,7 @@ class PlaybackService : MediaLibraryService() { override fun onCreate() { super.onCreate() - Logd(TAG, "Service created.") + Logd(TAG, "onCreate Service created.") isRunning = true playbackService = this @@ -1013,7 +1057,7 @@ class PlaybackService : MediaLibraryService() { // is FlowEvent.VolumeAdaptionChangedEvent -> onVolumeAdaptionChanged(event) is FlowEvent.FeedPrefsChangeEvent -> onFeedPrefsChanged(event) // is FlowEvent.SkipIntroEndingChangedEvent -> skipIntroEndingPresetChanged(event) - is FlowEvent.PlayEvent -> currentitem = event.episode + is FlowEvent.PlayEvent -> if (event.action != Action.END) currentitem = event.episode is FlowEvent.EpisodeMediaEvent -> onEpisodeMediaEvent(event) else -> {} } @@ -1036,10 +1080,10 @@ class PlaybackService : MediaLibraryService() { private fun onQueueEvent(event: FlowEvent.QueueEvent) { if (event.action == FlowEvent.QueueEvent.Action.REMOVED) { - Logd(TAG, "onQueueEvent: ending playback curEpisode ${curEpisode?.title}") +// Logd(TAG, "onQueueEvent: ending playback curEpisode ${curEpisode?.title}") notifyCurQueueItemsChanged() for (e in event.episodes) { - Logd(TAG, "onQueueEvent: ending playback event ${e.title}") + Logd(TAG, "onQueueEvent: queue event removed ${e.title}") if (e.id == curEpisode?.id) { mPlayer?.endPlayback(hasEnded = false, wasSkipped = true, shouldContinue = true, toStoppedState = true) break @@ -1157,7 +1201,8 @@ class PlaybackService : MediaLibraryService() { media.playedDuration = (media.playedDurationWhenStarted + media.getPosition() - media.startPosition) } } - EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item)) +// This appears not too useful +// EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item)) } } prevPosition = position @@ -1270,6 +1315,1105 @@ class PlaybackService : MediaLibraryService() { } } + /** + * Manages the MediaPlayer object of the PlaybackService. + */ + @UnstableApi + class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) { + + @Volatile + private var statusBeforeSeeking: PlayerStatus? = null + + @Volatile + private var videoSize: Pair? = null + private var isShutDown = false + private var seekLatch: CountDownLatch? = null + + private val bufferUpdateInterval = 5000L + private var mediaSource: MediaSource? = null + private var mediaItem: MediaItem? = null + private var playbackParameters: PlaybackParameters + + private var bufferedPercentagePrev = 0 + + private val formats: List + get() { + val formats_: MutableList = arrayListOf() + val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList() + val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) + for (i in 0 until trackGroups.length) { + formats_.add(trackGroups[i].getFormat(0)) + } + return formats_ + } + + private val audioRendererIndex: Int + get() { + for (i in 0 until exoPlayer!!.rendererCount) { + if (exoPlayer?.getRendererType(i) == C.TRACK_TYPE_AUDIO) return i + } + return -1 + } + + private val videoWidth: Int + get() { + return exoPlayer?.videoFormat?.width ?: 0 + } + + private val videoHeight: Int + get() { + return exoPlayer?.videoFormat?.height ?: 0 + } + + init { + if (httpDataSourceFactory == null) { + runOnIOScope { + httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) + .setUserAgent(ClientConfig.USER_AGENT) + } + } + if (exoPlayer == null) { + setupPlayerListener() + createStaticPlayer(context) + } + playbackParameters = exoPlayer!!.playbackParameters + val scope = CoroutineScope(Dispatchers.Main) + scope.launch { + while (true) { + delay(bufferUpdateInterval) + withContext(Dispatchers.Main) { + if (exoPlayer != null && bufferedPercentagePrev != exoPlayer!!.bufferedPercentage) { + bufferingUpdateListener?.accept(exoPlayer!!.bufferedPercentage) + bufferedPercentagePrev = exoPlayer!!.bufferedPercentage + } + } + } + } + } + + @Throws(IllegalStateException::class) + private fun prepareWR() { + Logd(TAG, "prepareWR() called") + if (mediaSource == null && mediaItem == null) return + if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false) + else exoPlayer?.setMediaItem(mediaItem!!) + exoPlayer?.prepare() + } + + private fun release() { + Logd(TAG, "release() called") + exoPlayer?.stop() + exoPlayer?.seekTo(0L) + audioSeekCompleteListener = null + audioCompletionListener = null + audioErrorListener = null + bufferingUpdateListener = null + } + + private fun setAudioStreamType(i: Int) { + val a = exoPlayer!!.audioAttributes + val b = AudioAttributes.Builder() + b.setContentType(i) + b.setFlags(a.flags) + b.setUsage(a.usage) + exoPlayer?.setAudioAttributes(b.build(), true) + } + + @Throws(IllegalArgumentException::class, IllegalStateException::class) + private fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) { + Logd(TAG, "setDataSource: $mediaUrl") + mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build() + mediaSource = null + setSourceCredentials(user, password) + } + + @Throws(IllegalArgumentException::class, IllegalStateException::class) + private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) { + Logd(TAG, "setDataSource1 called") + val url = media.getStreamUrl() ?: return + val preferences = media.episodeOrFetch()?.feed?.preferences + val user = preferences?.username + val password = preferences?.password + if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) { + Logd(TAG, "setDataSource1 setting for YouTube source") + try { + val vService = Vista.getService(0) + val streamInfo = StreamInfo.getInfo(vService, url) + val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams) + Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}") + val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1 + val audioStream = audioStreamsList[audioIndex] + Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}") + val aSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri( + Uri.parse(audioStream.content)).build()) + if (media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { + Logd(TAG, "setDataSource1 result: $streamInfo") + Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}") + val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true) + val videoIndex = 0 + val videoStream = videoStreamsList[videoIndex] + Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}") + val vSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri( + Uri.parse(videoStream.content)).build()) + val mediaSources: MutableList = ArrayList() + mediaSources.add(vSource) + mediaSources.add(aSource) + mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray()) +// mediaSource = null + } else mediaSource = aSource + mediaItem = mediaSource?.mediaItem + setSourceCredentials(user, password) + } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") } + } else { + Logd(TAG, "setDataSource1 setting for Podcast source") + setDataSource(metadata, url,user, password) + } + } + + private fun setSourceCredentials(user: String?, password: String?) { + if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) { + if (httpDataSourceFactory == null) + httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) + .setUserAgent(ClientConfig.USER_AGENT) + + val requestProperties = HashMap() + requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1") + httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties) + + val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!) + val extractorsFactory = DefaultExtractorsFactory() + extractorsFactory.setConstantBitrateSeekingEnabled(true) + extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA) + val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + + mediaSource = f.createMediaSource(mediaItem!!) + } + } + + /** + * Join the two lists of video streams (video_only and normal videos), + * and sort them according with default format chosen by the user. + * + * @param defaultFormat format to give preference + * @param showHigherResolutions show >1080p resolutions + * @param videoStreams normal videos list + * @param videoOnlyStreams video only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only + * streams and normal video streams are available + * @return the sorted list + */ + private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean, + preferVideoOnlyStreams: Boolean): List { + val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams) + val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList() + val comparator = compareBy { it.resolution.toResolutionValue() } + return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) } + } + + private fun String.toResolutionValue(): Int { + val match = Regex("(\\d+)p|(\\d+)k").find(this) + return when { + match?.groupValues?.get(1) != null -> match.groupValues[1].toInt() + match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024 + else -> 0 + } + } + + private fun getFilteredAudioStreams(audioStreams: List?): List { + if (audioStreams == null) return listOf() + val collectedStreams = mutableSetOf() + for (stream in audioStreams) { + Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}") + if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS)) + continue + collectedStreams.add(stream) + } + return collectedStreams.toList().sortedWith(compareBy { it.bitrate }) + } + + /** + * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing + * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will + * not do anything. + * Whether playback starts immediately depends on the given parameters. See below for more details. + * States: + * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. + * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If + * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. + * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object + * will enter the ERROR state. + * This method is executed on an internal executor service. + * @param playable The Playable object that is supposed to be played. This parameter must not be null. + * @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via + * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by + * the Android MediaPlayer via getStreamUrl. + * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the + * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared + * for playback immediately (see 'prepareImmediately' parameter for more details) + * @param prepareImmediately Set to true if the method should also prepare the episode for playback. + */ + override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { + Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ") +// showStackTrace() + if (curMedia != null) { + Logd(TAG, "playMediaObject: curMedia exist status=$status") + if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) { + Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.") + return + } + Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}") + // set temporarily to pause in order to update list with current position + if (status == PlayerStatus.PLAYING) { + val pos = curMedia?.getPosition() ?: -1 + seekTo(pos) + callback.onPlaybackPause(curMedia, pos) + } + // stop playback of this episode + if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop() +// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) +// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true) + setPlayerStatus(PlayerStatus.INDETERMINATE, null) + } + + Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}") + curMedia = playable + if (curMedia is EpisodeMedia) { + val media_ = curMedia as EpisodeMedia + val item = media_.episodeOrFetch() + val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf() + curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id) + } else curIndexInQueue = -1 + + prevMedia = curMedia + this.isStreaming = streaming + mediaType = curMedia!!.getMediaType() + videoSize = null + createMediaPlayer() + this.startWhenPrepared.set(startWhenPrepared) + setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) + val metadata = buildMetadata(curMedia!!) + try { + callback.ensureMediaInfoLoaded(curMedia!!) + callback.onMediaChanged(false) + setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence) + CoroutineScope(Dispatchers.IO).launch { + when { + streaming -> { + val streamurl = curMedia!!.getStreamUrl() + if (streamurl != null) { + val media = curMedia + if (media is EpisodeMedia) { + mediaItem = null + mediaSource = null + setDataSource(metadata, media) +// 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) + } + } + else -> { + val localMediaurl = curMedia!!.getLocalMediaUrl() +// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle +// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null) + if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) + else throw IOException("Unable to read local file $localMediaurl") + } + } + withContext(Dispatchers.Main) { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) + if (prepareImmediately) prepare() + } + } + } catch (e: IOException) { + e.printStackTrace() + setPlayerStatus(PlayerStatus.ERROR, null) + EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) + } catch (e: IllegalStateException) { + e.printStackTrace() + setPlayerStatus(PlayerStatus.ERROR, null) + EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) + } finally { } + } + + override fun resume() { + if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { + Logd(TAG, "Resuming/Starting playback") + acquireWifiLockIfNecessary() + setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence) + setVolume(1.0f, 1.0f) + + if (curMedia != null && status == PlayerStatus.PREPARED && curMedia!!.getPosition() > 0) { + val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime()) + seekTo(newPosition) + } + if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR() +// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) } + exoPlayer?.play() + // Can't set params when paused - so always set it on start in case they changed + exoPlayer?.playbackParameters = playbackParameters + setPlayerStatus(PlayerStatus.PLAYING, curMedia) + if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!)) + } else Logd(TAG, "Call to resume() was ignored because current state of PSMP object is $status") + } + + override fun pause(abandonFocus: Boolean, reinit: Boolean) { + releaseWifiLockIfNecessary() + if (status == PlayerStatus.PLAYING) { + Logd(TAG, "Pausing playback $abandonFocus $reinit") + exoPlayer?.pause() + setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition()) + if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END)) + if (isStreaming && reinit) reinit() + } else Logd(TAG, "Ignoring call to pause: Player is in $status state") + } + + override fun prepare() { + if (status == PlayerStatus.INITIALIZED) { + Logd(TAG, "Preparing media player") + setPlayerStatus(PlayerStatus.PREPARING, curMedia) + prepareWR() +// onPrepared(startWhenPrepared.get()) + if (mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight) + if (curMedia != null) { + val pos = curMedia!!.getPosition() + if (pos > 0) seekTo(pos) + if (curMedia != null && curMedia!!.getDuration() <= 0) { + Logd(TAG, "Setting duration of media") + curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt()) + } + } + setPlayerStatus(PlayerStatus.PREPARED, curMedia) + if (startWhenPrepared.get()) resume() + } + } + + override fun reinit() { + Logd(TAG, "reinit() called") + releaseWifiLockIfNecessary() + when { + curMedia != null -> playMediaObject(curMedia!!, isStreaming, startWhenPrepared.get(), prepareImmediately = false, true) + else -> Logd(TAG, "Call to reinit: media and mediaPlayer were null, ignored") + } + } + + override fun seekTo(t: Int) { + var t = t + if (t < 0) t = 0 + Logd(TAG, "seekTo() called $t") + + if (t >= getDuration()) { + Logd(TAG, "Seek reached end of file, skipping to next episode") + exoPlayer?.seekTo(t.toLong()) // can set curMedia to null + if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration())) + audioSeekCompleteListener?.run() + endPlayback(true, wasSkipped = true, true, toStoppedState = true) + t = getPosition() +// return + } + + when (status) { + PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { + Logd(TAG, "seekTo() called $t") + if (seekLatch != null && seekLatch!!.count > 0) { + try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) } + } + seekLatch = CountDownLatch(1) + statusBeforeSeeking = status + setPlayerStatus(PlayerStatus.SEEKING, curMedia, t) + exoPlayer?.seekTo(t.toLong()) + if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration())) + audioSeekCompleteListener?.run() + if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t) + try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) } + } + PlayerStatus.INITIALIZED -> { + curMedia?.setPosition(t) + startWhenPrepared.set(false) + prepare() + } + else -> {} + } + } + + override fun getDuration(): Int { + return curMedia?.getDuration() ?: Playable.INVALID_TIME + } + + override fun getPosition(): Int { + var retVal = Playable.INVALID_TIME + if (exoPlayer != null && status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt() + if (retVal <= 0 && curMedia != null) retVal = curMedia!!.getPosition() + return retVal + } + + override fun setPlaybackParams(speed: Float, skipSilence: Boolean) { + EventFlow.postEvent(FlowEvent.SpeedChangedEvent(speed)) + Logd(TAG, "setPlaybackParams speed=$speed pitch=${playbackParameters.pitch} skipSilence=$skipSilence") + playbackParameters = PlaybackParameters(speed, playbackParameters.pitch) + exoPlayer!!.skipSilenceEnabled = skipSilence + exoPlayer!!.playbackParameters = playbackParameters + } + + override fun getPlaybackSpeed(): Float { + var retVal = 1f + if (status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED || status == PlayerStatus.INITIALIZED || status == PlayerStatus.PREPARED) + retVal = playbackParameters.speed + return retVal + } + + override fun setVolume(volumeLeft: Float, volumeRight: Float) { + var volumeLeft = volumeLeft + var volumeRight = volumeRight + val playable = curMedia + if (playable is EpisodeMedia) { + val preferences = playable.episodeOrFetch()?.feed?.preferences + if (preferences != null) { + val volumeAdaptionSetting = preferences.volumeAdaptionSetting + if (volumeAdaptionSetting != null) { + val adaptionFactor = volumeAdaptionSetting.adaptionFactor + volumeLeft *= adaptionFactor + volumeRight *= adaptionFactor + } + } + } + if (volumeLeft > 1) { + exoPlayer?.volume = 1f + loudnessEnhancer?.setEnabled(true) + loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt()) + } else { + exoPlayer?.volume = volumeLeft + loudnessEnhancer?.setEnabled(false) + } + Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight") + } + + override fun shutdown() { + Logd(TAG, "shutdown() called") + try { + clearMediaPlayerListeners() +// TODO: should use: exoPlayer!!.playWhenReady ? + if (exoPlayer?.isPlaying == true) exoPlayer?.stop() + } catch (e: Exception) { + e.printStackTrace() + } + release() + status = PlayerStatus.STOPPED + + isShutDown = true + releaseWifiLockIfNecessary() + } + + override fun setVideoSurface(surface: SurfaceHolder?) { + exoPlayer?.setVideoSurfaceHolder(surface) + } + + override fun resetVideoSurface() { + if (mediaType == MediaType.VIDEO) { + Logd(TAG, "Resetting video surface") + exoPlayer?.setVideoSurfaceHolder(null) + reinit() + } else Log.e(TAG, "Resetting video surface for media of Audio type") + } + + /** + * Return width and height of the currently playing video as a pair. + * @return Width and height as a Pair or null if the video size could not be determined. The method might still + * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return + * invalid values. + */ + override fun getVideoSize(): Pair? { + if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight) + return videoSize + } + + override fun getAudioTracks(): List { + val trackNames: MutableList = ArrayList() + val trackNameProvider: TrackNameProvider = DefaultTrackNameProvider(context.resources) + for (format in formats) { + trackNames.add(trackNameProvider.getTrackName(format)) + } + return trackNames + } + + override fun setAudioTrack(track: Int) { + val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return + val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) + val override = SelectionOverride(track, 0) + val rendererIndex = audioRendererIndex + val params = trackSelector!!.buildUponParameters().setSelectionOverride(rendererIndex, trackGroups, override) + trackSelector!!.setParameters(params) + } + + override fun getSelectedAudioTrack(): Int { + val trackSelections = exoPlayer!!.currentTrackSelections + val availableFormats = formats + Logd(TAG, "selectedAudioTrack called tracks: ${trackSelections.length} formats: ${availableFormats.size}") + for (i in 0 until trackSelections.length) { + val track = trackSelections[i] as? ExoTrackSelection ?: continue + if (availableFormats.contains(track.selectedFormat)) return availableFormats.indexOf(track.selectedFormat) + } + return -1 + } + + override fun createMediaPlayer() { + Logd(TAG, "createMediaPlayer()") + release() + if (curMedia == null) { + status = PlayerStatus.STOPPED + return + } + setAudioStreamType(C.AUDIO_CONTENT_TYPE_SPEECH) + setMediaPlayerListeners() + } + + override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean, toStoppedState: Boolean) { + releaseWifiLockIfNecessary() + if (curMedia == null) return + + val isPlaying = status == PlayerStatus.PLAYING + // we're relying on the position stored in the Playable object for post-playback processing + val position = getPosition() + if (position >= 0) curMedia?.setPosition(position) + Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState") + + val currentMedia = curMedia + var nextMedia: Playable? = null + if (shouldContinue) { + // Load next episode if previous episode was in the queue and if there is an episode in the queue left. + // Start playback immediately if continuous playback is enabled + nextMedia = callback.getNextInQueue(currentMedia) + if (nextMedia != null) { + Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false") + if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null) + callback.onPlaybackEnded(nextMedia.getMediaType(), false) + // setting media to null signals to playMediaObject that we're taking care of post-playback processing + curMedia = null + playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying) + } + } + when { + shouldContinue || toStoppedState -> { + if (nextMedia == null) { + Logd(TAG, "nextMedia is null. call callback.onPlaybackEnded true") + callback.onPlaybackEnded(null, true) + curMedia = null + exoPlayer?.stop() + releaseWifiLockIfNecessary() + if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null) + else Logd(TAG, "Ignored call to stop: Current player state is: $status") + } + val hasNext = nextMedia != null + if (currentMedia != null) callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext) +// curMedia = nextMedia + } + isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia!!.getPosition()) + } + } + + override fun shouldLockWifi(): Boolean { + return isStreaming + } + + private fun setMediaPlayerListeners() { + if (curMedia == null) return + + audioCompletionListener = Runnable { + Logd(TAG, "audioCompletionListener called") + endPlayback(hasEnded = true, wasSkipped = false, shouldContinue = true, toStoppedState = true) + } + audioSeekCompleteListener = Runnable { this.genericSeekCompleteListener() } + bufferingUpdateListener = Consumer { percent: Int -> + when (percent) { + BUFFERING_STARTED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.started()) + BUFFERING_ENDED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.ended()) + else -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.progressUpdate(0.01f * percent)) + } + } + audioErrorListener = Consumer { message: String -> + Log.e(TAG, "PlayerErrorEvent: $message") + EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(message)) + } + } + + private fun clearMediaPlayerListeners() { + audioCompletionListener = Runnable {} + audioSeekCompleteListener = Runnable {} + bufferingUpdateListener = Consumer { } + audioErrorListener = Consumer {} + } + + private fun genericSeekCompleteListener() { + Logd(TAG, "genericSeekCompleteListener $status ${exoPlayer?.isPlaying} $statusBeforeSeeking") + seekLatch?.countDown() + + if ((status == PlayerStatus.PLAYING || exoPlayer?.isPlaying != true) && curMedia != null) callback.onPlaybackStart(curMedia!!, getPosition()) + if (status == PlayerStatus.SEEKING && statusBeforeSeeking != null) setPlayerStatus(statusBeforeSeeking!!, curMedia, getPosition()) + } + + override fun isCasting(): Boolean { + return false + } + + private fun setupPlayerListener() { + exoplayerListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: @State Int) { + Logd(TAG, "onPlaybackStateChanged $playbackState") + when (playbackState) { + STATE_ENDED -> { + exoPlayer?.seekTo(C.TIME_UNSET) + audioCompletionListener?.run() + } + STATE_BUFFERING -> bufferingUpdateListener?.accept(BUFFERING_STARTED) + else -> bufferingUpdateListener?.accept(BUFFERING_ENDED) + } + } + override fun onIsPlayingChanged(isPlaying: Boolean) { + val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED + setPlayerStatus(stat, curMedia) + Logd(TAG, "onIsPlayingChanged $isPlaying") + } + override fun onPlayerError(error: PlaybackException) { + Logd(TAG, "onPlayerError ${error.message}") + if (wasDownloadBlocked(error)) audioErrorListener?.accept(context.getString(R.string.download_error_blocked)) + else { + var cause = error.cause + if (cause is HttpDataSourceException && cause.cause != null) cause = cause.cause + if (cause != null && "Source error" == cause.message) cause = cause.cause + audioErrorListener?.accept((if (cause != null) cause.message else error.message) ?:"no message") + } + } + override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, reason: @DiscontinuityReason Int) { + Logd(TAG, "onPositionDiscontinuity $oldPosition $newPosition $reason") + if (reason == DISCONTINUITY_REASON_SEEK) audioSeekCompleteListener?.run() + } + override fun onAudioSessionIdChanged(audioSessionId: Int) { + Logd(TAG, "onAudioSessionIdChanged $audioSessionId") + initLoudnessEnhancer(audioSessionId) + } + } + } + + companion object { + private val TAG: String = LocalMediaPlayer::class.simpleName ?: "Anonymous" + + const val BUFFERING_STARTED: Int = -1 + const val BUFFERING_ENDED: Int = -2 + + private var httpDataSourceFactory: OkHttpDataSource.Factory? = null + + private var trackSelector: DefaultTrackSelector? = null + + var exoPlayer: ExoPlayer? = null + + private var exoplayerListener: Player.Listener? = null + private var audioSeekCompleteListener: java.lang.Runnable? = null + private var audioCompletionListener: java.lang.Runnable? = null + private var audioErrorListener: Consumer? = null + private var bufferingUpdateListener: Consumer? = null + private var loudnessEnhancer: LoudnessEnhancer? = null + + fun createStaticPlayer(context: Context) { + val loadControl = DefaultLoadControl.Builder() + loadControl.setBufferDurationsMs(30000, 120000, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + loadControl.setBackBuffer(rewindSecs * 1000 + 500, true) + trackSelector = DefaultTrackSelector(context) + val audioOffloadPreferences = AudioOffloadPreferences.Builder() + .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) // Add additional options as needed + .setIsGaplessSupportRequired(true) + .setIsSpeedChangeSupportRequired(true) + .build() + Logd(TAG, "createStaticPlayer creating exoPlayer_") + + val defaultRenderersFactory = DefaultRenderersFactory(context) +// defaultRenderersFactory.setMediaCodecSelector { mimeType: String?, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean -> +// val decoderInfos: List = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder) +// val result: MutableList = ArrayList() +// for (decoderInfo in decoderInfos) { +// Logd(TAG, "decoderInfo.name: ${decoderInfo.name}") +// if (decoderInfo.name == "c2.android.mp3.decoder") { +// continue +// } +// result.add(decoderInfo) +// } +// result +// } + exoPlayer = ExoPlayer.Builder(context, defaultRenderersFactory) + .setTrackSelector(trackSelector!!) + .setLoadControl(loadControl.build()) + .build() + + exoPlayer?.setSeekParameters(SeekParameters.EXACT) + exoPlayer!!.trackSelectionParameters = exoPlayer!!.trackSelectionParameters + .buildUpon() + .setAudioOffloadPreferences(audioOffloadPreferences) + .build() + +// if (BuildConfig.DEBUG) exoPlayer!!.addAnalyticsListener(EventLogger()) + + if (exoplayerListener != null) { + exoPlayer?.removeListener(exoplayerListener!!) + exoPlayer?.addListener(exoplayerListener!!) + } + initLoudnessEnhancer(exoPlayer!!.audioSessionId) + } + + private fun initLoudnessEnhancer(audioStreamId: Int) { + 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 + } + } + + fun cleanup() { + if (exoplayerListener != null) exoPlayer?.removeListener(exoplayerListener!!) + exoplayerListener = null + audioSeekCompleteListener = null + audioCompletionListener = null + audioErrorListener = null + bufferingUpdateListener = null + loudnessEnhancer = null + httpDataSourceFactory = null + } + } + } + + /** + * Manages the background tasks of PlaybackSerivce, i.e. + * the sleep timer, the position saver, the widget updater and the queue loader. + * + * The PlaybackServiceTaskManager(PSTM) uses a callback object (PSTMCallback) + * to notify the PlaybackService about updates from the running tasks. + */ + class TaskManager(private val context: Context, private val callback: PSTMCallback) { + private val schedExecutor: ScheduledThreadPoolExecutor = ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE) { r: java.lang.Runnable? -> + val t = Thread(r) + t.priority = Thread.MIN_PRIORITY + t + } + + private var positionSaverFuture: ScheduledFuture<*>? = null + private var widgetUpdaterFuture: ScheduledFuture<*>? = null + private var sleepTimerFuture: ScheduledFuture<*>? = null + +// @Volatile +// private var chapterLoaderFuture: Disposable? = null + + private var sleepTimer: SleepTimer? = null + + /** + * Returns true if the sleep timer is currently active. + */ + @get:Synchronized + val isSleepTimerActive: Boolean + get() = sleepTimerFuture?.isCancelled == false && sleepTimerFuture?.isDone == false && (sleepTimer?.getWaitingTime() ?: 0) > 0 + + /** + * Returns the current sleep timer time or 0 if the sleep timer is not active. + */ + @get:Synchronized + val sleepTimerTimeLeft: Long + get() = if (isSleepTimerActive) sleepTimer!!.getWaitingTime() else 0 + + /** + * Returns true if the widget updater is currently running. + */ + @get:Synchronized + val isWidgetUpdaterActive: Boolean + get() = widgetUpdaterFuture != null && !widgetUpdaterFuture!!.isCancelled && !widgetUpdaterFuture!!.isDone + + /** + * Returns true if the position saver is currently running. + */ + @get:Synchronized + val isPositionSaverActive: Boolean + get() = positionSaverFuture != null && !positionSaverFuture!!.isCancelled && !positionSaverFuture!!.isDone + + /** + * Starts the position saver task. If the position saver is already active, nothing will happen. + */ + @Synchronized + fun startPositionSaver() { + if (!isPositionSaverActive) { + var positionSaver = Runnable { callback.positionSaverTick() } + positionSaver = useMainThreadIfNecessary(positionSaver) + positionSaverFuture = schedExecutor.scheduleWithFixedDelay( + positionSaver, POSITION_SAVER_WAITING_INTERVAL.toLong(), POSITION_SAVER_WAITING_INTERVAL.toLong(), TimeUnit.MILLISECONDS) + Logd(TAG, "Started PositionSaver") + } else Logd(TAG, "Call to startPositionSaver was ignored.") + } + + /** + * Cancels the position saver. If the position saver is not running, nothing will happen. + */ + @Synchronized + fun cancelPositionSaver() { + if (isPositionSaverActive) { + positionSaverFuture!!.cancel(false) + Logd(TAG, "Cancelled PositionSaver") + } + } + + /** + * Starts the widget updater task. If the widget updater is already active, nothing will happen. + */ + @Synchronized + fun startWidgetUpdater() { + if (!isWidgetUpdaterActive && !schedExecutor.isShutdown) { + var widgetUpdater = Runnable { this.requestWidgetUpdate() } + widgetUpdater = useMainThreadIfNecessary(widgetUpdater) + widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay( + widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), TimeUnit.MILLISECONDS) + Logd(TAG, "Started WidgetUpdater") + } + } + + /** + * Retrieves information about the widget state in the calling thread and then displays it in a background thread. + */ + @Synchronized + fun requestWidgetUpdate() { + val state = callback.requestWidgetState() + if (!schedExecutor.isShutdown) schedExecutor.execute { WidgetUpdater.updateWidget(context, state) } + else Logd(TAG, "Call to requestWidgetUpdate was ignored.") + } + + /** + * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be + * cancelled first. + * After waitingTime has elapsed, onSleepTimerExpired() will be called. + * + * @throws java.lang.IllegalArgumentException if waitingTime <= 0 + */ + @Synchronized + fun setSleepTimer(waitingTime: Long) { + require(waitingTime > 0) { "Waiting time <= 0" } + + Logd(TAG, "Setting sleep timer to $waitingTime milliseconds") + if (isSleepTimerActive) sleepTimerFuture!!.cancel(true) + sleepTimer = SleepTimer(waitingTime) + sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS) + EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.justEnabled(waitingTime)) + } + + /** + * Disables the sleep timer. If the sleep timer is not active, nothing will happen. + */ + @Synchronized + fun disableSleepTimer() { + if (isSleepTimerActive) { + Logd(TAG, "Disabling sleep timer") + sleepTimer!!.cancel() + } + } + + /** + * Restarts the sleep timer. If the sleep timer is not active, nothing will happen. + */ + @Synchronized + fun restartSleepTimer() { + if (isSleepTimerActive) { + Logd(TAG, "Restarting sleep timer") + sleepTimer!!.restart() + } + } + + /** + * Cancels the widget updater. If the widget updater is not running, nothing will happen. + */ + @Synchronized + fun cancelWidgetUpdater() { + if (isWidgetUpdaterActive) { + widgetUpdaterFuture!!.cancel(false) + Logd(TAG, "Cancelled WidgetUpdater") + } + } + + /** + * Starts a new thread that loads the chapter marks from a playable object. If another chapter loader is already active, + * it will be cancelled first. + * On completion, the callback's onChapterLoaded method will be called. + */ + @Synchronized + fun startChapterLoader(media: Playable) { +// chapterLoaderFuture?.dispose() +// chapterLoaderFuture = null + + if (!media.chaptersLoaded()) { + val scope = CoroutineScope(Dispatchers.Main) + scope.launch(Dispatchers.IO) { + try { + ChapterUtils.loadChapters(media, context, false) + withContext(Dispatchers.Main) { callback.onChapterLoaded(media) } + } catch (e: Throwable) { + Logd(TAG, "Error loading chapters: ${Log.getStackTraceString(e)}") + } + } + } + } + + /** + * Cancels all tasks. The PSTM will be in the initial state after execution of this method. + */ + @Synchronized + fun cancelAllTasks() { + cancelPositionSaver() + cancelWidgetUpdater() + disableSleepTimer() + +// chapterLoaderFuture?.dispose() +// chapterLoaderFuture = null + } + + /** + * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after + * execution of this method. + */ + fun shutdown() { + cancelAllTasks() + schedExecutor.shutdownNow() + } + + private fun useMainThreadIfNecessary(runnable: java.lang.Runnable): java.lang.Runnable { + if (Looper.myLooper() == Looper.getMainLooper()) { + // Called in main thread => ExoPlayer is used + // Run on ui thread even if called from schedExecutor + val handler = Handler(Looper.getMainLooper()) + return Runnable { handler.post(runnable) } + } else return runnable + } + + /** + * Sleeps for a given time and then pauses playback. + */ + internal inner class SleepTimer(private val waitingTime: Long) : java.lang.Runnable { + private var hasVibrated = false + private var timeLeft = waitingTime + private var shakeListener: ShakeListener? = null + + override fun run() { + Logd(TAG, "Starting SleepTimer") + var lastTick = System.currentTimeMillis() + EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft)) + while (timeLeft > 0) { + try { + Thread.sleep(SLEEP_TIMER_UPDATE_INTERVAL) + } catch (e: InterruptedException) { + Logd(TAG, "Thread was interrupted while waiting") + e.printStackTrace() + break + } + + val now = System.currentTimeMillis() + timeLeft -= now - lastTick + lastTick = now + + EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft)) + if (timeLeft < NOTIFICATION_THRESHOLD) { + Logd(TAG, "Sleep timer is about to expire") + if (SleepTimerPreferences.vibrate() && !hasVibrated) { + val v = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + if (v != null) { + v.vibrate(500) + hasVibrated = true + } + } + if (shakeListener == null && SleepTimerPreferences.shakeToReset()) shakeListener = ShakeListener(context, this) + } + if (timeLeft <= 0) { + Logd(TAG, "Sleep timer expired") + shakeListener?.pause() + shakeListener = null + + hasVibrated = false + } + } + } + fun getWaitingTime(): Long { + return timeLeft + } + fun restart() { + EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled()) + setSleepTimer(waitingTime) + shakeListener?.pause() + shakeListener = null + } + fun cancel() { + sleepTimerFuture!!.cancel(true) + shakeListener?.pause() + EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled()) + } + } + + interface PSTMCallback { + fun positionSaverTick() + fun requestWidgetState(): WidgetState + fun onChapterLoaded(media: Playable?) + } + + internal class ShakeListener(private val mContext: Context, private val mSleepTimer: SleepTimer) : + SensorEventListener { + private var mAccelerometer: Sensor? = null + private var mSensorMgr: SensorManager? = null + + init { + resume() + } + private fun resume() { + // only a precaution, the user should actually not be able to activate shake to reset + // when the accelerometer is not available + mSensorMgr = mContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager + if (mSensorMgr == null) throw UnsupportedOperationException("Sensors not supported") + + mAccelerometer = mSensorMgr!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + if (!mSensorMgr!!.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_UI)) { // if not supported + mSensorMgr!!.unregisterListener(this) + throw UnsupportedOperationException("Accelerometer not supported") + } + } + fun pause() { + mSensorMgr?.unregisterListener(this) + mSensorMgr = null + } + override fun onSensorChanged(event: SensorEvent) { + val gX = event.values[0] / SensorManager.GRAVITY_EARTH + val gY = event.values[1] / SensorManager.GRAVITY_EARTH + val gZ = event.values[2] / SensorManager.GRAVITY_EARTH + + val gForce = sqrt((gX * gX + gY * gY + gZ * gZ).toDouble()) + if (gForce > 2.25) { + Logd(TAG, "Detected shake $gForce") + mSleepTimer.restart() + } + } + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} + companion object { + private val TAG: String = ShakeListener::class.simpleName ?: "Anonymous" + } + } + + companion object { + private val TAG: String = TaskManager::class.simpleName ?: "Anonymous" + + private const val SCHED_EX_POOL_SIZE = 2 + + private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds + const val POSITION_SAVER_WAITING_INTERVAL: Int = 5000 // in millisoconds + const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 // in millisoconds + const val NOTIFICATION_THRESHOLD: Long = 10000 // in millisoconds + } + } + companion object { private val TAG: String = PlaybackService::class.simpleName ?: "Anonymous" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt deleted file mode 100644 index 267fcff4..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt +++ /dev/null @@ -1,354 +0,0 @@ -package ac.mdiq.podcini.playback.service - -import ac.mdiq.podcini.preferences.SleepTimerPreferences -import ac.mdiq.podcini.storage.model.Playable -import ac.mdiq.podcini.ui.widget.WidgetUpdater -import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState -import ac.mdiq.podcini.storage.utils.ChapterUtils -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import android.content.Context -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import android.os.Handler -import android.os.Looper -import android.os.Vibrator -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.TimeUnit -import kotlin.math.sqrt - -/** - * Manages the background tasks of PlaybackSerivce, i.e. - * the sleep timer, the position saver, the widget updater and - * the queue loader. - * - * - * The PlaybackServiceTaskManager(PSTM) uses a callback object (PSTMCallback) - * to notify the PlaybackService about updates from the running tasks. - */ -class TaskManager(private val context: Context, private val callback: PSTMCallback) { - private val schedExecutor: ScheduledThreadPoolExecutor = ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE) { r: Runnable? -> - val t = Thread(r) - t.priority = Thread.MIN_PRIORITY - t - } - - private var positionSaverFuture: ScheduledFuture<*>? = null - private var widgetUpdaterFuture: ScheduledFuture<*>? = null - private var sleepTimerFuture: ScheduledFuture<*>? = null - -// @Volatile -// private var chapterLoaderFuture: Disposable? = null - - private var sleepTimer: SleepTimer? = null - - /** - * Returns true if the sleep timer is currently active. - */ - @get:Synchronized - val isSleepTimerActive: Boolean - get() = sleepTimerFuture?.isCancelled == false && sleepTimerFuture?.isDone == false && (sleepTimer?.getWaitingTime() ?: 0) > 0 - - /** - * Returns the current sleep timer time or 0 if the sleep timer is not active. - */ - @get:Synchronized - val sleepTimerTimeLeft: Long - get() = if (isSleepTimerActive) sleepTimer!!.getWaitingTime() else 0 - - /** - * Returns true if the widget updater is currently running. - */ - @get:Synchronized - val isWidgetUpdaterActive: Boolean - get() = widgetUpdaterFuture != null && !widgetUpdaterFuture!!.isCancelled && !widgetUpdaterFuture!!.isDone - - /** - * Returns true if the position saver is currently running. - */ - @get:Synchronized - val isPositionSaverActive: Boolean - get() = positionSaverFuture != null && !positionSaverFuture!!.isCancelled && !positionSaverFuture!!.isDone - - /** - * Starts the position saver task. If the position saver is already active, nothing will happen. - */ - @Synchronized - fun startPositionSaver() { - if (!isPositionSaverActive) { - var positionSaver = Runnable { callback.positionSaverTick() } - positionSaver = useMainThreadIfNecessary(positionSaver) - positionSaverFuture = schedExecutor.scheduleWithFixedDelay( - positionSaver, POSITION_SAVER_WAITING_INTERVAL.toLong(), POSITION_SAVER_WAITING_INTERVAL.toLong(), TimeUnit.MILLISECONDS) - Logd(TAG, "Started PositionSaver") - } else Logd(TAG, "Call to startPositionSaver was ignored.") - } - - /** - * Cancels the position saver. If the position saver is not running, nothing will happen. - */ - @Synchronized - fun cancelPositionSaver() { - if (isPositionSaverActive) { - positionSaverFuture!!.cancel(false) - Logd(TAG, "Cancelled PositionSaver") - } - } - - /** - * Starts the widget updater task. If the widget updater is already active, nothing will happen. - */ - @Synchronized - fun startWidgetUpdater() { - if (!isWidgetUpdaterActive && !schedExecutor.isShutdown) { - var widgetUpdater = Runnable { this.requestWidgetUpdate() } - widgetUpdater = useMainThreadIfNecessary(widgetUpdater) - widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay( - widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), TimeUnit.MILLISECONDS) - Logd(TAG, "Started WidgetUpdater") - } - } - - /** - * Retrieves information about the widget state in the calling thread and then displays it in a background thread. - */ - @Synchronized - fun requestWidgetUpdate() { - val state = callback.requestWidgetState() - if (!schedExecutor.isShutdown) schedExecutor.execute { WidgetUpdater.updateWidget(context, state) } - else Logd(TAG, "Call to requestWidgetUpdate was ignored.") - } - - /** - * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be - * cancelled first. - * After waitingTime has elapsed, onSleepTimerExpired() will be called. - * - * @throws java.lang.IllegalArgumentException if waitingTime <= 0 - */ - @Synchronized - fun setSleepTimer(waitingTime: Long) { - require(waitingTime > 0) { "Waiting time <= 0" } - - Logd(TAG, "Setting sleep timer to $waitingTime milliseconds") - if (isSleepTimerActive) sleepTimerFuture!!.cancel(true) - sleepTimer = SleepTimer(waitingTime) - sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS) - EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.justEnabled(waitingTime)) - } - - /** - * Disables the sleep timer. If the sleep timer is not active, nothing will happen. - */ - @Synchronized - fun disableSleepTimer() { - if (isSleepTimerActive) { - Logd(TAG, "Disabling sleep timer") - sleepTimer!!.cancel() - } - } - - /** - * Restarts the sleep timer. If the sleep timer is not active, nothing will happen. - */ - @Synchronized - fun restartSleepTimer() { - if (isSleepTimerActive) { - Logd(TAG, "Restarting sleep timer") - sleepTimer!!.restart() - } - } - - /** - * Cancels the widget updater. If the widget updater is not running, nothing will happen. - */ - @Synchronized - fun cancelWidgetUpdater() { - if (isWidgetUpdaterActive) { - widgetUpdaterFuture!!.cancel(false) - Logd(TAG, "Cancelled WidgetUpdater") - } - } - - /** - * Starts a new thread that loads the chapter marks from a playable object. If another chapter loader is already active, - * it will be cancelled first. - * On completion, the callback's onChapterLoaded method will be called. - */ - @Synchronized - fun startChapterLoader(media: Playable) { -// chapterLoaderFuture?.dispose() -// chapterLoaderFuture = null - - if (!media.chaptersLoaded()) { - val scope = CoroutineScope(Dispatchers.Main) - scope.launch(Dispatchers.IO) { - try { - ChapterUtils.loadChapters(media, context, false) - withContext(Dispatchers.Main) { callback.onChapterLoaded(media) } - } catch (e: Throwable) { - Logd(TAG, "Error loading chapters: ${Log.getStackTraceString(e)}") - } - } - } - } - - /** - * Cancels all tasks. The PSTM will be in the initial state after execution of this method. - */ - @Synchronized - fun cancelAllTasks() { - cancelPositionSaver() - cancelWidgetUpdater() - disableSleepTimer() - -// chapterLoaderFuture?.dispose() -// chapterLoaderFuture = null - } - - /** - * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after - * execution of this method. - */ - fun shutdown() { - cancelAllTasks() - schedExecutor.shutdownNow() - } - - private fun useMainThreadIfNecessary(runnable: Runnable): Runnable { - if (Looper.myLooper() == Looper.getMainLooper()) { - // Called in main thread => ExoPlayer is used - // Run on ui thread even if called from schedExecutor - val handler = Handler(Looper.getMainLooper()) - return Runnable { handler.post(runnable) } - } else return runnable - } - - /** - * Sleeps for a given time and then pauses playback. - */ - internal inner class SleepTimer(private val waitingTime: Long) : Runnable { - private var hasVibrated = false - private var timeLeft = waitingTime - private var shakeListener: ShakeListener? = null - - override fun run() { - Logd(TAG, "Starting SleepTimer") - var lastTick = System.currentTimeMillis() - EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft)) - while (timeLeft > 0) { - try { - Thread.sleep(SLEEP_TIMER_UPDATE_INTERVAL) - } catch (e: InterruptedException) { - Logd(TAG, "Thread was interrupted while waiting") - e.printStackTrace() - break - } - - val now = System.currentTimeMillis() - timeLeft -= now - lastTick - lastTick = now - - EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft)) - if (timeLeft < NOTIFICATION_THRESHOLD) { - Logd(TAG, "Sleep timer is about to expire") - if (SleepTimerPreferences.vibrate() && !hasVibrated) { - val v = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - if (v != null) { - v.vibrate(500) - hasVibrated = true - } - } - if (shakeListener == null && SleepTimerPreferences.shakeToReset()) shakeListener = ShakeListener(context, this) - } - if (timeLeft <= 0) { - Logd(TAG, "Sleep timer expired") - shakeListener?.pause() - shakeListener = null - - hasVibrated = false - } - } - } - fun getWaitingTime(): Long { - return timeLeft - } - fun restart() { - EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled()) - setSleepTimer(waitingTime) - shakeListener?.pause() - shakeListener = null - } - fun cancel() { - sleepTimerFuture!!.cancel(true) - shakeListener?.pause() - EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled()) - } - } - - interface PSTMCallback { - fun positionSaverTick() - fun requestWidgetState(): WidgetState - fun onChapterLoaded(media: Playable?) - } - - internal class ShakeListener(private val mContext: Context, private val mSleepTimer: SleepTimer) : SensorEventListener { - private var mAccelerometer: Sensor? = null - private var mSensorMgr: SensorManager? = null - - init { - resume() - } - private fun resume() { - // only a precaution, the user should actually not be able to activate shake to reset - // when the accelerometer is not available - mSensorMgr = mContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager - if (mSensorMgr == null) throw UnsupportedOperationException("Sensors not supported") - - mAccelerometer = mSensorMgr!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) - if (!mSensorMgr!!.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_UI)) { // if not supported - mSensorMgr!!.unregisterListener(this) - throw UnsupportedOperationException("Accelerometer not supported") - } - } - fun pause() { - mSensorMgr?.unregisterListener(this) - mSensorMgr = null - } - override fun onSensorChanged(event: SensorEvent) { - val gX = event.values[0] / SensorManager.GRAVITY_EARTH - val gY = event.values[1] / SensorManager.GRAVITY_EARTH - val gZ = event.values[2] / SensorManager.GRAVITY_EARTH - - val gForce = sqrt((gX * gX + gY * gY + gZ * gZ).toDouble()) - if (gForce > 2.25) { - Logd(TAG, "Detected shake $gForce") - mSleepTimer.restart() - } - } - override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} - companion object { - private val TAG: String = ShakeListener::class.simpleName ?: "Anonymous" - } - } - - companion object { - private val TAG: String = TaskManager::class.simpleName ?: "Anonymous" - - private const val SCHED_EX_POOL_SIZE = 2 - - private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds - const val POSITION_SAVER_WAITING_INTERVAL: Int = 5000 // in millisoconds - const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 // in millisoconds - const val NOTIFICATION_THRESHOLD: Long = 10000 // in millisoconds - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt index 615dc049..cd28a176 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt @@ -96,19 +96,6 @@ object UserPreferences { } } - var videoPlaybackSpeed: Float - get() { - try { return appPrefs.getString(Prefs.prefVideoPlaybackSpeed.name, "1.00")!!.toFloat() - } catch (e: NumberFormatException) { - Log.e(TAG, Log.getStackTraceString(e)) - videoPlaybackSpeed = 1.0f - return 1.0f - } - } - set(speed) { - appPrefs.edit().putString(Prefs.prefVideoPlaybackSpeed.name, speed.toString()).apply() - } - var isSkipSilence: Boolean get() = appPrefs.getBoolean(Prefs.prefSkipSilence.name, false) set(skipSilence) { @@ -364,7 +351,6 @@ object UserPreferences { // Mediaplayer prefPlaybackSpeed, - prefVideoPlaybackSpeed, prefSkipSilence, prefFastForwardSecs, prefRewindSecs, @@ -372,7 +358,7 @@ object UserPreferences { prefVideoPlaybackMode, } - // Constants + @Suppress("ClassName") enum class NOTIFICATION_BUTTON { REWIND, FAST_FORWARD, diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt index a5e54441..09d827ae 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt @@ -262,7 +262,6 @@ object Queues { } if (queueNew.id == curQueue.id) { queueNew.episodes.clear() -// queueNew.episodes.addAll(qItems) curQueue = queueNew } for (event in events) EventFlow.postEvent(event) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt index f3f8cad1..7cc54b3f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt @@ -1,7 +1,7 @@ package ac.mdiq.podcini.storage.model enum class MediaType { - AUDIO, VIDEO, FLASH, UNKNOWN; + AUDIO, VIDEO, UNKNOWN; companion object { private val AUDIO_APPLICATION_MIME_STRINGS: Set = HashSet(mutableListOf( @@ -9,7 +9,6 @@ enum class MediaType { "application/opus", "application/x-flac" )) - private val VIDEO_APPLICATION_MIME_STRINGS: Set = HashSet(mutableListOf("application/x-shockwave-flash")) fun fromMimeType(mimeType: String?): MediaType { return when { @@ -17,7 +16,6 @@ enum class MediaType { mimeType.startsWith("audio") -> AUDIO mimeType.startsWith("video") -> VIDEO AUDIO_APPLICATION_MIME_STRINGS.contains(mimeType) -> AUDIO - VIDEO_APPLICATION_MIME_STRINGS.contains(mimeType) -> FLASH else -> UNKNOWN } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt index 2ccebc79..9ff20af0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt @@ -81,7 +81,7 @@ interface Playable : Parcelable, Serializable { * Returns an url to a local file that can be played or null if this file * does not exist. */ - fun getLocalMediaUrl(): String? + fun getLocalMediaUrl(): String? { return null} /** * Returns an url to a file that can be streamed by the player or null if @@ -93,7 +93,7 @@ interface Playable : Parcelable, Serializable { * Returns true if a local file that can be played is available. getFileUrl * MUST return a non-null string if this method returns true. */ - fun localFileAvailable(): Boolean + fun localFileAvailable(): Boolean { return false} /** * This method should be called every time playback starts on this object. @@ -101,7 +101,7 @@ interface Playable : Parcelable, Serializable { * * Position held by this Playable should be set accurately before a call to this method is made. */ - fun onPlaybackStart() + fun onPlaybackStart() {} /** * This method should be called every time playback pauses or stops on this object, @@ -112,13 +112,13 @@ interface Playable : Parcelable, Serializable { * * Position held by this Playable should be set accurately before a call to this method is made. */ - fun onPlaybackPause(context: Context) + fun onPlaybackPause(context: Context) {} /** * This method should be called when playback completes for this object. * @param context */ - fun onPlaybackCompleted(context: Context) + fun onPlaybackCompleted(context: Context) {} /** * Returns an integer that must be unique among all Playable classes. The diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt index f9840934..697659c3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt @@ -139,13 +139,13 @@ class RemoteMedia : Playable { return streamUrl } - override fun getLocalMediaUrl(): String? { - return null - } +// override fun getLocalMediaUrl(): String? { +// return null +// } - override fun localFileAvailable(): Boolean { - return false - } +// override fun localFileAvailable(): Boolean { +// return false +// } override fun setPosition(newPosition: Int) { position = newPosition @@ -159,17 +159,17 @@ class RemoteMedia : Playable { this.lastPlayedTime = lastPlayedTime } - override fun onPlaybackStart() { - // no-op - } +// override fun onPlaybackStart() { +// // no-op +// } - override fun onPlaybackPause(context: Context) { - // no-op - } +// override fun onPlaybackPause(context: Context) { +// // no-op +// } - override fun onPlaybackCompleted(context: Context) { - // no-op - } +// override fun onPlaybackCompleted(context: Context) { +// // no-op +// } override fun getPlayableType(): Int { return PLAYABLE_TYPE_REMOTE_MEDIA } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt index 3c9fb1fe..f600dab0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt @@ -34,7 +34,7 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis icon.setImageResource(getDrawable()) } - protected fun playVideo(context: Context, media: Playable) { + protected fun playVideoIfNeeded(context: Context, media: Playable) { if (item.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY && videoPlayMode != VideoMode.AUDIO_ONLY.code && videoMode != VideoMode.AUDIO_ONLY && media.getMediaType() == MediaType.VIDEO) @@ -50,7 +50,7 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis } // Logd("ItemActionButton", "forItem: ${episode.feedId} ${episode.feed?.isLocalFeed} ${media.downloaded} ${isCurrentlyPlaying(media)} ${episode.title} ") return when { - media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode) +// media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode) isCurrentlyPlaying(media) -> PauseActionButton(episode) episode.feed != null && episode.feed!!.isLocalFeed -> PlayLocalActionButton(episode) media.downloaded -> PlayActionButton(episode) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt index 5e5e7861..d89592b7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt @@ -46,11 +46,7 @@ class PlayActionButton(item: Episode) : EpisodeActionButton(item) { EventFlow.postEvent(FlowEvent.PlayEvent(item)) } -// if (item.feed?.preferences?.videoModePolicy != FeedPreferences.VideomodePolicy.AUDIO_ONLY -// && videoPlayMode != VideoMode.AUDIO_ONLY.mode && videoMode != VideoMode.AUDIO_ONLY -// && media.getMediaType() == MediaType.VIDEO) -// context.startActivity(getPlayerActivityIntent(context, MediaType.VIDEO)) - playVideo(context, media) + playVideoIfNeeded(context, media) } /** diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt index f36ef13a..3f5691ed 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt @@ -30,23 +30,14 @@ class StreamActionButton(item: Episode) : EpisodeActionButton(item) { // Logd("StreamActionButton", "item.feed: ${item.feedId}") val media = if (item.feedId != null) item.media!! else RemoteMedia(item) logAction(UsageStatistics.ACTION_STREAM) - if (!isStreamingAllowed) { StreamingConfirmationDialog(context, media).show() return } - - PlaybackServiceStarter(context, media) - .shouldStreamThisTime(true) - .callEvenIfRunning(true) - .start() + PlaybackServiceStarter(context, media).shouldStreamThisTime(true).callEvenIfRunning(true).start() EventFlow.postEvent(FlowEvent.PlayEvent(item)) -// if (item.feed?.preferences?.videoModePolicy != FeedPreferences.VideomodePolicy.AUDIO_ONLY -// && videoPlayMode != VideoMode.AUDIO_ONLY.mode && videoMode != VideoMode.AUDIO_ONLY -// && media.getMediaType() == MediaType.VIDEO) -// context.startActivity(getPlayerActivityIntent(context, MediaType.VIDEO)) - playVideo(context, media) + playVideoIfNeeded(context, media) } class StreamingConfirmationDialog(private val context: Context, private val playable: Playable) { @@ -63,13 +54,9 @@ class StreamActionButton(item: Episode) : EpisodeActionButton(item) { .setNeutralButton(R.string.cancel_label, null) .show() } - @UnstableApi private fun stream() { - PlaybackServiceStarter(context, playable) - .callEvenIfRunning(true) - .shouldStreamThisTime(true) - .start() + PlaybackServiceStarter(context, playable).callEvenIfRunning(true).shouldStreamThisTime(true).start() } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 7376e185..9307d3ba 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -282,7 +282,7 @@ class MainActivity : CastEnabledActivity() { } if (downloadUrl == null) continue - Logd(TAG, "workInfo.state: ${workInfo.state}") +// Logd(TAG, "workInfo.state: ${workInfo.state}") var status: Int status = when (workInfo.state) { WorkInfo.State.RUNNING -> DownloadStatus.STATE_RUNNING 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 aeb63521..4250ee94 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 @@ -3,8 +3,8 @@ 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.service.DownloadServiceInterface +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed import ac.mdiq.podcini.playback.base.InTheatre @@ -19,7 +19,6 @@ 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.* @@ -81,7 +80,6 @@ import okhttp3.Request.Builder import java.io.File import java.lang.ref.WeakReference import java.util.* -import kotlin.collections.ArrayList import kotlin.math.max /** @@ -121,9 +119,11 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac @UnstableApi fun refreshPosCallback(pos: Int, episode: Episode) { Logd(TAG, "refreshPosCallback: $pos ${episode.title}") - if (pos >= 0 && pos < episodes.size) episodes[pos] = episode + if (pos >= 0 && pos < episodes.size && episodes[pos].id == episode.id) { + episodes[pos] = episode // notifyItemChanged(pos, "foo") - refreshFragPosCallback?.invoke(pos, episode) + refreshFragPosCallback?.invoke(pos, episode) + } } fun clearData() { @@ -132,11 +132,6 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac notifyDataSetChanged() } -// fun setDummyViews(dummyViews: Int) { -// this.dummyViews = dummyViews -// notifyDataSetChanged() -// } - fun updateItems(items: MutableList, feed_: Feed? = null) { episodes = items feed = feed_ @@ -156,12 +151,8 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac @UnstableApi override fun onBindViewHolder(holder: EpisodeViewHolder, pos: Int) { -// Logd(TAG, "onBindViewHolder $pos ${episodes[pos].title}") + Logd(TAG, "onBindViewHolder $pos ${episodes[pos].title}") if (pos >= episodes.size || pos < 0) { -// beforeBindViewHolder(holder, pos) -// holder.bindDummy() -// afterBindViewHolder(holder, pos) -// holder.hideSeparatorIfNecessary() Logd(TAG, "onBindViewHolder got invalid pos: $pos of ${episodes.size}") return } @@ -539,14 +530,14 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac } if (episode != null) { actionButton1 = when { - media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!) +// 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!!) + 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!!) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt index 89d06478..917fb53f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt @@ -1,25 +1,23 @@ package ac.mdiq.podcini.ui.dialog +//import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.SpeedSelectDialogBinding import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence -import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration import ac.mdiq.podcini.ui.view.PlaybackSpeedSeekBar -import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.Logd import android.os.Bundle import android.os.Handler import android.os.Looper @@ -165,9 +163,8 @@ open class VariableSpeedDialog : BottomSheetDialogFragment() { private fun addCurrentSpeed() { val newSpeed = speedSeekBar.currentSpeed - if (selectedSpeeds.contains(newSpeed)) { - Snackbar.make(addCurrentSpeedChip, getString(R.string.preset_already_exists, newSpeed), Snackbar.LENGTH_LONG).show() - } else { + if (selectedSpeeds.contains(newSpeed)) Snackbar.make(addCurrentSpeedChip, getString(R.string.preset_already_exists, newSpeed), Snackbar.LENGTH_LONG).show() + else { selectedSpeeds.add(newSpeed) selectedSpeeds.sort() playbackSpeedArray = selectedSpeeds @@ -248,28 +245,20 @@ open class VariableSpeedDialog : BottomSheetDialogFragment() { playbackService!!.isSpeedForward = false playbackService!!.isFallbackSpeed = false - if (currentMediaType == MediaType.VIDEO) { - setCurTempSpeed(speed) - videoPlaybackSpeed = speed - playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence) - } else { - if (codeArray != null && codeArray.size == 3) { - Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}") - if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed) - if (codeArray[1]) { - val episode = (curMedia as? EpisodeMedia)?.episodeOrFetch() ?: curEpisode - if (episode?.feed?.preferences != null) { - upsertBlk(episode.feed!!) { it.preferences!!.playSpeed = speed } - } - } - if (codeArray[0]) { - setCurTempSpeed(speed) - playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence) - } - } else { + if (codeArray != null && codeArray.size == 3) { + Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}") + if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed) + if (codeArray[1]) { + val episode = (curMedia as? EpisodeMedia)?.episodeOrFetch() ?: curEpisode + if (episode?.feed?.preferences != null) upsertBlk(episode.feed!!) { it.preferences!!.playSpeed = speed } + } + if (codeArray[0]) { setCurTempSpeed(speed) playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence) } + } else { + setCurTempSpeed(speed) + playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence) } } else { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt index 9cdf9fcd..8cee4e48 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt @@ -12,7 +12,6 @@ import ac.mdiq.podcini.net.feed.FeedUrlNotFoundException import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry import ac.mdiq.podcini.net.feed.parser.FeedHandler -import ac.mdiq.podcini.net.feed.parser.FeedHandlerResult import ac.mdiq.podcini.net.utils.HtmlToPlainText import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload @@ -394,7 +393,7 @@ class OnlineFeedFragment : Fragment() { * @throws Exception If unsuccessful but we do not know a resolution. */ @Throws(Exception::class) - private fun doParseFeed(destination: String): FeedHandlerResult? { + private fun doParseFeed(destination: String): FeedHandler.FeedHandlerResult? { val destinationFile = File(destination) return try { val feed = Feed(selectedDownloadUrl, null) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt index 735151ef..c29e8741 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt @@ -110,17 +110,15 @@ class PlayerDetailsFragment : Fragment() { return binding.root } - override fun onStart() { - Logd(TAG, "onStart()") - super.onStart() -// procFlowEvents() - } +// override fun onStart() { +// Logd(TAG, "onStart()") +// super.onStart() +// } - override fun onStop() { - Logd(TAG, "onStop()") - super.onStop() -// cancelFlowEvents() - } +// override fun onStop() { +// Logd(TAG, "onStop()") +// super.onStop() +// } override fun onDestroyView() { Logd(TAG, "onDestroyView") @@ -229,9 +227,8 @@ class PlayerDetailsFragment : Fragment() { val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!) binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) } } - } else { - binding.txtvPodcastTitle.setOnClickListener(null) - } + } else binding.txtvPodcastTitle.setOnClickListener(null) + binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) } binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr) binding.txtvEpisodeTitle.text = currentItem?.title @@ -256,7 +253,6 @@ class PlayerDetailsFragment : Fragment() { set.start() } } - displayedChapterIndex = -1 refreshChapterData(ChapterUtils.getCurrentChapterIndex(media, media.getPosition())) //calls displayCoverImage updateChapterControlVisibility() @@ -357,7 +353,6 @@ class PlayerDetailsFragment : Fragment() { @UnstableApi private fun seekToNextChapter() { if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= playable!!.getChapters().size) return - refreshChapterData(displayedChapterIndex + 1) seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) } @@ -399,7 +394,6 @@ class PlayerDetailsFragment : Fragment() { } Logd(TAG, "reset scroll Position: 0") binding.itemDescriptionFragment.scrollTo(0, 0) - return true } } @@ -414,9 +408,7 @@ class PlayerDetailsFragment : Fragment() { fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { if (playable?.getIdentifier() != event.media?.getIdentifier()) return val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(playable, event.position) - if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) { - refreshChapterData(newChapterIndex) - } + if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) refreshChapterData(newChapterIndex) } fun setItem(item_: Episode) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index f6b077ec..91d82109 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -84,6 +84,7 @@ import com.leinardi.android.speeddial.SpeedDialView import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import java.util.* +import kotlin.math.max /** * Shows all items in the queue. @@ -153,7 +154,7 @@ import java.util.* curQueue = upsertBlk(queues[position]) { it.update() } toolbar.menu?.findItem(R.id.rename_queue)?.setVisible(curQueue.name != "Default") loadCurQueue(true) - playbackService?.notifyCurQueueItemsChanged(Math.max(prevQueueSize, curQueue.size())) + playbackService?.notifyCurQueueItemsChanged(max(prevQueueSize, curQueue.size())) } override fun onNothingSelected(parent: AdapterView<*>?) {} } @@ -231,14 +232,11 @@ import java.util.* procFlowEvents() val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java)) browserFuture = MediaBrowser.Builder(requireContext(), sessionToken).buildAsync() - browserFuture.addListener( - { - // here we can get the root of media items tree or we can get also the children if it is an album for example. - mediaBrowser = browserFuture.get() - mediaBrowser?.subscribe("CurQueue", null) - }, - MoreExecutors.directExecutor() - ) + browserFuture.addListener({ + // here we can get the root of media items tree or we can get also the children if it is an album for example. + mediaBrowser = browserFuture.get() + mediaBrowser?.subscribe("CurQueue", null) + }, MoreExecutors.directExecutor()) // if (queueItems.isNotEmpty()) recyclerView.restoreScrollPosition(TAG) } @@ -309,7 +307,7 @@ import java.util.* } private fun refreshPosCallback(pos: Int, episode: Episode) { - Logd(TAG, "Queue refreshPosCallback: $pos ${episode.title}") +// Logd(TAG, "Queue refreshPosCallback: $pos ${episode.title}") if (isAdded && activity != null) refreshInfoBar() } @@ -340,12 +338,15 @@ import java.util.* val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, e.id) if (pos >= 0) { Logd(TAG, "removing episode $pos ${queueItems[pos].title} $e") +// val holder = recyclerView.findViewHolderForItemId(e.id) as? EpisodeViewHolder val holder = recyclerView.findViewHolderForLayoutPosition(pos) as? EpisodeViewHolder - holder?.stopDBMonitor() + if (holder != null) { + holder.stopDBMonitor() // holder?.unbind() - queueItems.removeAt(pos) - adapter?.notifyItemRemoved(pos) - adapter?.notifyItemRangeChanged(pos, adapter!!.itemCount - pos) + queueItems.removeAt(pos) + adapter?.notifyItemRemoved(pos) +// adapter?.notifyItemRangeChanged(pos, adapter!!.itemCount - pos) + } } else { Log.e(TAG, "Trying to remove item non-existent from queue ${e.id} ${e.title}") continue @@ -370,8 +371,8 @@ import java.util.* } private fun onPlayEvent(event: FlowEvent.PlayEvent) { - Logd(TAG, "onPlayEvent ${event.episode.title}") val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, event.episode.id) + Logd(TAG, "onPlayEvent action: ${event.action} pos: $pos ${event.episode.title}") if (pos >= 0) adapter?.notifyItemChangedCompat(pos) } @@ -380,9 +381,7 @@ import java.util.* if (loadItemsRunning) return for (downloadUrl in event.urls) { val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(queueItems.toList(), downloadUrl) - if (pos >= 0) { - adapter?.notifyItemChangedCompat(pos) - } + if (pos >= 0) adapter?.notifyItemChangedCompat(pos) } } @@ -417,7 +416,6 @@ import java.util.* @SuppressLint("RestrictedApi") private fun onKeyUp(event: KeyEvent) { if (!isAdded || !isVisible || !isMenuVisible) return - when (event.keyCode) { KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0) KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(adapter!!.itemCount - 1) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt index f45ac122..6c7a4e9a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt @@ -90,7 +90,7 @@ open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGro private var mediaMonitor: Job? = null private var notBond: Boolean = true - val isCurMedia: Boolean + private val isCurMedia: Boolean get() = InTheatre.isCurMedia(this.episode?.media) init { @@ -295,33 +295,6 @@ open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGro } } - fun bindDummy() { - this.episode = Episode() - binding.container.alpha = 0.1f - secondaryActionIcon.setImageDrawable(null) - isVideo.visibility = View.GONE - binding.isFavorite.visibility = View.GONE - isInQueue.visibility = View.GONE - title.text = "███████" - pubDate.text = "████" - duration.text = "████" - secondaryActionProgress.setPercentage(0f, null) - secondaryActionProgress.setIndeterminate(false) - progressBar.visibility = View.GONE - position.visibility = View.GONE - dragHandle.visibility = View.GONE - binding.size.text = "" - itemView.setBackgroundResource(ThemeUtils.getDrawableFromAttr(activity, androidx.appcompat.R.attr.selectableItemBackground)) - placeholder.text = "" - if (coverHolder.visibility == View.VISIBLE) { - CoverLoader(activity) - .withResource(R.color.medium_gray) - .withPlaceholderView(placeholder) - .withCoverView(cover) - .load() - } - } - fun updatePlaybackPositionNew(item: Episode) { Logd(TAG, "updatePlaybackPositionNew called") this.episode = item diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt index 68345bcb..fb6267f7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt @@ -13,6 +13,14 @@ import android.content.SharedPreferences class EpisodesRecyclerView : RecyclerView { private lateinit var layoutManager: LinearLayoutManager + val isScrolledToBottom: Boolean + get() { + val visibleEpisodeCount = childCount + val totalEpisodeCount = layoutManager.itemCount + val firstVisibleEpisode = layoutManager.findFirstVisibleItemPosition() + return (totalEpisodeCount - visibleEpisodeCount) <= (firstVisibleEpisode + 3) + } + constructor(context: Context) : super(ContextThemeWrapper(context, R.style.FastScrollRecyclerView)) { setup() } @@ -59,14 +67,6 @@ class EpisodesRecyclerView : RecyclerView { if (position > 0 || offset > 0) layoutManager.scrollToPositionWithOffset(position, offset) } - val isScrolledToBottom: Boolean - get() { - val visibleEpisodeCount = childCount - val totalEpisodeCount = layoutManager.itemCount - val firstVisibleEpisode = layoutManager.findFirstVisibleItemPosition() - return (totalEpisodeCount - visibleEpisodeCount) <= (firstVisibleEpisode + 3) - } - companion object { private val TAG: String = EpisodesRecyclerView::class.simpleName ?: "Anonymous" private const val PREF_PREFIX_SCROLL_POSITION = "scroll_position_" diff --git a/changelog.md b/changelog.md index 86391acd..f1fb4b1e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +# 6.5.8 + +* corrected mis-behavior of speed settings for video media +* likely fixed issue of duplicates or absence of playing episode seen sometimes in Queues view +* reduced some unnecessary posting of events +* removed setting of videoPlaybackSpeed, audio and video speed how handled in the same way +* removed incomplete handling of flash media previously used to handle youtube media + # 6.5.7 * in every feed settings, in case the preferences are not properly set, auto-download is by default disabled diff --git a/fastlane/metadata/android/en-US/changelogs/3020242.txt b/fastlane/metadata/android/en-US/changelogs/3020242.txt new file mode 100644 index 00000000..1b1f10c4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020242.txt @@ -0,0 +1,7 @@ + Version 6.5.8 brings several changes: + +* corrected mis-behavior of speed settings for video media +* likely fixed issue of duplicates or absence of playing episode seen sometimes in Queues view +* reduced some unnecessary posting of events +* removed setting of videoPlaybackSpeed, audio and video speed how handled in the same way +* removed incomplete handling of flash media previously used to handle youtube media