6.5.8 commit

This commit is contained in:
Xilin Jia 2024-09-09 22:54:46 +01:00
parent 24cb13cd88
commit e3f7c31407
32 changed files with 1292 additions and 1507 deletions

View File

@ -12,13 +12,11 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
[<img src="./images/external/getItf-droid.png" alt="F-Droid" height="50">](https://f-droid.org/packages/ac.mdiq.podcini.R/)
[<img src="./images/external/amazon.png" alt="Amazon" height="40">](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](<https://github.com/AntennaPod/AntennaPod>) as of Feb 5 2024.
This project evolves from a fork of [AntennaPod](<https://github.com/AntennaPod/AntennaPod>) as of Feb 5 2024.
Compared to AntennaPod this project:

View File

@ -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 = ""

View File

@ -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

View File

@ -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

View File

@ -54,7 +54,6 @@
android:enabled="true"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService"/>
<action android:name="android.media.browse.MediaBrowserService"/>

View File

@ -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

View File

@ -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<String, String>,
val redirectUrl: String)
companion object {
private val TAG: String = FeedHandler::class.simpleName ?: "Anonymous"
private const val ATOM_ROOT = "feed"

View File

@ -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"
}
}

View File

@ -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

View File

@ -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))

View File

@ -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
}
}
}

View File

@ -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<Int, Int>? = 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<Format>
get() {
val formats_: MutableList<Format> = 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<MediaSource> = ArrayList()
mediaSources.add(vSource)
mediaSources.add(aSource)
mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray<MediaSource>())
// 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<String, String>()
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<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
preferVideoOnlyStreams: Boolean): List<VideoStream> {
val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
val comparator = compareBy<VideoStream> { 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<AudioStream>?): List<AudioStream> {
if (audioStreams == null) return listOf()
val collectedStreams = mutableSetOf<AudioStream>()
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<Int, Int>? {
if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
return videoSize
}
override fun getAudioTracks(): List<String> {
val trackNames: MutableList<String> = 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<String>? = null
private var bufferingUpdateListener: Consumer<Int>? = 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<MediaCodecInfo> = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder)
// val result: MutableList<MediaCodecInfo> = 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
}
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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<String> = HashSet(mutableListOf(
@ -9,7 +9,6 @@ enum class MediaType {
"application/opus",
"application/x-flac"
))
private val VIDEO_APPLICATION_MIME_STRINGS: Set<String> = 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
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}
/**

View File

@ -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()
}
}
}

View File

@ -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

View File

@ -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<Episode>, 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!!)

View File

@ -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 {

View File

@ -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)

View File

@ -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) {

View File

@ -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)

View File

@ -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

View File

@ -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_"

View File

@ -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

View File

@ -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