mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 21:16:09 +02:00
m3, ui/ux, and more
This commit is contained in:
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
package com.doublesymmetry.kotlinaudio.event
|
||||
|
||||
class EventHolder internal constructor(private val notificationEventHolder: NotificationEventHolder, private val playerEventHolder: PlayerEventHolder) {
|
||||
val audioItemTransition
|
||||
get() = playerEventHolder.audioItemTransition
|
||||
|
||||
val notificationStateChange
|
||||
get() = notificationEventHolder.notificationStateChange
|
||||
|
||||
val onAudioFocusChanged
|
||||
get() = playerEventHolder.onAudioFocusChanged
|
||||
|
||||
val onCommonMetadata
|
||||
get() = playerEventHolder.onCommonMetadata
|
||||
|
||||
val onTimedMetadata
|
||||
get() = playerEventHolder.onTimedMetadata
|
||||
|
||||
val onPlayerActionTriggeredExternally
|
||||
get() = playerEventHolder.onPlayerActionTriggeredExternally
|
||||
|
||||
val playbackEnd
|
||||
get() = playerEventHolder.playbackEnd
|
||||
|
||||
val playWhenReadyChange
|
||||
get() = playerEventHolder.playWhenReadyChange
|
||||
|
||||
val stateChange
|
||||
get() = playerEventHolder.stateChange
|
||||
|
||||
val playbackError
|
||||
get() = playerEventHolder.playbackError
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.doublesymmetry.kotlinaudio.event
|
||||
|
||||
import com.doublesymmetry.kotlinaudio.models.NotificationState
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NotificationEventHolder {
|
||||
private val coroutineScope = MainScope()
|
||||
|
||||
private var _notificationStateChange = MutableSharedFlow<NotificationState>(1)
|
||||
var notificationStateChange = _notificationStateChange.asSharedFlow()
|
||||
|
||||
internal fun updateNotificationState(state: NotificationState) {
|
||||
coroutineScope.launch {
|
||||
_notificationStateChange.emit(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.doublesymmetry.kotlinaudio.event
|
||||
|
||||
import com.doublesymmetry.kotlinaudio.models.*
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlayerEventHolder {
|
||||
private val coroutineScope = MainScope()
|
||||
|
||||
private var _stateChange = MutableSharedFlow<AudioPlayerState>(1)
|
||||
var stateChange = _stateChange.asSharedFlow()
|
||||
|
||||
private var _playbackEnd = MutableSharedFlow<PlaybackEndedReason?>(1)
|
||||
var playbackEnd = _playbackEnd.asSharedFlow()
|
||||
|
||||
private var _playbackError = MutableSharedFlow<PlaybackError>(1)
|
||||
var playbackError = _playbackError.asSharedFlow()
|
||||
|
||||
private var _playWhenReadyChange = MutableSharedFlow<PlayWhenReadyChangeData>(1)
|
||||
/**
|
||||
* Use these events to track when [com.doublesymmetry.kotlinaudio.players.BaseAudioPlayer.playWhenReady]
|
||||
* changes.
|
||||
*/
|
||||
var playWhenReadyChange = _playWhenReadyChange.asSharedFlow()
|
||||
|
||||
private var _audioItemTransition = MutableSharedFlow<AudioItemTransitionReason?>(1)
|
||||
|
||||
/**
|
||||
* Use these events to track when and why an [AudioItem] transitions to another.
|
||||
*
|
||||
* Examples of an audio transition include changes to [AudioItem] queue, an [AudioItem] on repeat, skipping an [AudioItem], or simply when the [AudioItem] has finished.
|
||||
*/
|
||||
var audioItemTransition = _audioItemTransition.asSharedFlow()
|
||||
|
||||
private var _positionChanged = MutableSharedFlow<PositionChangedReason?>(1)
|
||||
var positionChanged = _positionChanged.asSharedFlow()
|
||||
|
||||
private var _onAudioFocusChanged = MutableSharedFlow<FocusChangeData>(1)
|
||||
var onAudioFocusChanged = _onAudioFocusChanged.asSharedFlow()
|
||||
|
||||
private var _onCommonMetadata = MutableSharedFlow<MediaMetadata>(1)
|
||||
var onCommonMetadata = _onCommonMetadata.asSharedFlow()
|
||||
|
||||
private var _onTimedMetadata = MutableSharedFlow<Metadata>(1)
|
||||
var onTimedMetadata = _onTimedMetadata.asSharedFlow()
|
||||
|
||||
private var _onPlayerActionTriggeredExternally = MutableSharedFlow<MediaSessionCallback>()
|
||||
|
||||
/**
|
||||
* Use these events to track whenever a player action has been triggered from an outside source.
|
||||
*
|
||||
* The sources can be: media buttons on headphones, Android Wear, Android Auto, Google Assistant, media notification, etc.
|
||||
*
|
||||
* For this observable to send events, set [interceptPlayerActionsTriggeredExternally][com.doublesymmetry.kotlinaudio.models.PlayerConfig.interceptPlayerActionsTriggeredExternally] to true.
|
||||
*/
|
||||
var onPlayerActionTriggeredExternally = _onPlayerActionTriggeredExternally.asSharedFlow()
|
||||
|
||||
internal fun updateAudioPlayerState(state: AudioPlayerState) {
|
||||
coroutineScope.launch {
|
||||
_stateChange.emit(state)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updatePlaybackEndedReason(reason: PlaybackEndedReason) {
|
||||
coroutineScope.launch {
|
||||
_playbackEnd.emit(reason)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updatePlayWhenReadyChange(playWhenReadyChange: PlayWhenReadyChangeData) {
|
||||
coroutineScope.launch {
|
||||
_playWhenReadyChange.emit(playWhenReadyChange)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAudioItemTransition(reason: AudioItemTransitionReason) {
|
||||
coroutineScope.launch {
|
||||
_audioItemTransition.emit(reason)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updatePositionChangedReason(reason: PositionChangedReason) {
|
||||
coroutineScope.launch {
|
||||
_positionChanged.emit(reason)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateOnAudioFocusChanged(isPaused: Boolean, isPermanent: Boolean) {
|
||||
coroutineScope.launch {
|
||||
_onAudioFocusChanged.emit(FocusChangeData(isPaused, isPermanent))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateOnCommonMetadata(metadata: MediaMetadata) {
|
||||
coroutineScope.launch {
|
||||
_onCommonMetadata.emit(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateOnTimedMetadata(metadata: Metadata) {
|
||||
coroutineScope.launch {
|
||||
_onTimedMetadata.emit(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updatePlaybackError(error: PlaybackError) {
|
||||
coroutineScope.launch {
|
||||
_playbackError.emit(error)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateOnPlayerActionTriggeredExternally(callback: MediaSessionCallback) {
|
||||
coroutineScope.launch {
|
||||
_onPlayerActionTriggeredExternally.emit(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
enum class AudioContentType {
|
||||
MUSIC,
|
||||
SPEECH,
|
||||
SONIFICATION,
|
||||
MOVIE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
interface AudioItem {
|
||||
var audioUrl: String
|
||||
val type: MediaType
|
||||
var artist: String?
|
||||
var title: String?
|
||||
var albumTitle: String?
|
||||
val artwork: String?
|
||||
val duration: Long?
|
||||
val options: AudioItemOptions?
|
||||
}
|
||||
|
||||
data class AudioItemOptions(
|
||||
val headers: MutableMap<String, String>? = null,
|
||||
val userAgent: String? = null,
|
||||
val resourceId: Int? = null
|
||||
)
|
||||
|
||||
enum class MediaType(val value: String) {
|
||||
/**
|
||||
* The default media type. Should be used for streams over HTTP or files
|
||||
*/
|
||||
DEFAULT("default"),
|
||||
|
||||
/**
|
||||
* The DASH media type for adaptive streams. Should be used with DASH manifests.
|
||||
*/
|
||||
DASH("dash"),
|
||||
|
||||
/**
|
||||
* The HLS media type for adaptive streams. Should be used with HLS playlists.
|
||||
*/
|
||||
HLS("hls"),
|
||||
|
||||
/**
|
||||
* The SmoothStreaming media type for adaptive streams. Should be used with SmoothStreaming manifests.
|
||||
*/
|
||||
SMOOTH_STREAMING("smoothstreaming");
|
||||
}
|
||||
|
||||
data class DefaultAudioItem(
|
||||
override var audioUrl: String,
|
||||
|
||||
/**
|
||||
* Set to [MediaType.DEFAULT] by default.
|
||||
*/
|
||||
override val type: MediaType = MediaType.DEFAULT,
|
||||
|
||||
override var artist: String? = null,
|
||||
override var title: String? = null,
|
||||
override var albumTitle: String? = null,
|
||||
override var artwork: String? = null,
|
||||
override val duration: Long? = null,
|
||||
override val options: AudioItemOptions? = null,
|
||||
) : AudioItem
|
||||
|
||||
class AudioItemHolder(
|
||||
var audioItem: AudioItem
|
||||
) {
|
||||
var artworkBitmap: Bitmap? = null
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
/**
|
||||
* Use these events to track when and why an [AudioItem] transitions to another.
|
||||
* Examples of an audio transition include changes to [AudioItem] queue, an [AudioItem] on repeat, skipping an [AudioItem], or simply when the [AudioItem] has finished.
|
||||
*/
|
||||
sealed class AudioItemTransitionReason(val oldPosition: Long) {
|
||||
/**
|
||||
* Playback has automatically transitioned to the next [AudioItem].
|
||||
*
|
||||
* This reason also indicates a transition caused by another player.
|
||||
*/
|
||||
class AUTO(oldPosition: Long) : AudioItemTransitionReason(oldPosition)
|
||||
|
||||
/**
|
||||
* A seek to another [AudioItem] has occurred. Usually triggered when calling
|
||||
* [QueuedAudioPlayer.next][com.doublesymmetry.kotlinaudio.players.QueuedAudioPlayer.next]
|
||||
* or [QueuedAudioPlayer.previous][com.doublesymmetry.kotlinaudio.players.QueuedAudioPlayer.previous].
|
||||
*/
|
||||
class SEEK_TO_ANOTHER_AUDIO_ITEM(oldPosition: Long) : AudioItemTransitionReason(oldPosition)
|
||||
|
||||
/**
|
||||
* The [AudioItem] has been repeated.
|
||||
*/
|
||||
class REPEAT(oldPosition: Long) : AudioItemTransitionReason(oldPosition)
|
||||
|
||||
/**
|
||||
* The current [AudioItem] has changed because of a change in the queue. This can either be if
|
||||
* the [AudioItem] previously being played has been removed, or when the queue becomes non-empty
|
||||
* after being empty.
|
||||
*/
|
||||
class QUEUE_CHANGED(oldPosition: Long) : AudioItemTransitionReason(oldPosition)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
enum class AudioPlayerState {
|
||||
/** The current [AudioItem] is being loaded for playback. */
|
||||
LOADING,
|
||||
|
||||
/** The current [AudioItem] is loaded, and the player is ready to start playing. */
|
||||
READY,
|
||||
|
||||
/** The current [AudioItem] is currently buffering. */
|
||||
BUFFERING,
|
||||
|
||||
/** The player is paused. */
|
||||
PAUSED,
|
||||
|
||||
/** The player is stopped. */
|
||||
STOPPED,
|
||||
|
||||
/** The player is playing. */
|
||||
PLAYING,
|
||||
|
||||
/** No [AudioItem] is loaded and the player is doing nothing. */
|
||||
IDLE,
|
||||
|
||||
/** Playback stopped due to the end of the queue being reached. */
|
||||
ENDED,
|
||||
|
||||
/** The player stopped playing due to an error. */
|
||||
ERROR
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
data class BufferConfig(
|
||||
val minBuffer: Int?,
|
||||
val maxBuffer: Int?,
|
||||
val playBuffer: Int?,
|
||||
val backBuffer: Int?,
|
||||
)
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
/**
|
||||
* Configuration for cache properties of player.
|
||||
*/
|
||||
data class CacheConfig(
|
||||
/**
|
||||
* Maximum player cache size in kilobytes.
|
||||
*/
|
||||
val maxCacheSize: Long?,
|
||||
|
||||
/**
|
||||
* Cache identifier, used to make cache directory.
|
||||
*/
|
||||
val identifier: String = "TrackPlayer"
|
||||
)
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
enum class Capability {
|
||||
PLAY,
|
||||
PLAY_FROM_ID,
|
||||
PLAY_FROM_SEARCH,
|
||||
PAUSE,
|
||||
STOP,
|
||||
SEEK_TO,
|
||||
SKIP,
|
||||
SKIP_TO_NEXT,
|
||||
SKIP_TO_PREVIOUS,
|
||||
JUMP_FORWARD,
|
||||
JUMP_BACKWARD,
|
||||
SET_RATING,
|
||||
LIKE,
|
||||
DISLIKE,
|
||||
BOOKMARK
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
data class FocusChangeData(val isPaused: Boolean, val isFocusLostPermanently: Boolean)
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.RatingCompat
|
||||
|
||||
sealed class MediaSessionCallback {
|
||||
class RATING(val rating: RatingCompat, extras: Bundle?): MediaSessionCallback()
|
||||
object PLAY : MediaSessionCallback()
|
||||
object PAUSE : MediaSessionCallback()
|
||||
object NEXT : MediaSessionCallback()
|
||||
object PREVIOUS : MediaSessionCallback()
|
||||
object FORWARD : MediaSessionCallback()
|
||||
object REWIND : MediaSessionCallback()
|
||||
object STOP : MediaSessionCallback()
|
||||
class SEEK(val positionMs: Long): MediaSessionCallback()
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
import android.app.PendingIntent
|
||||
import androidx.annotation.DrawableRes
|
||||
|
||||
/**
|
||||
* Used to configure the player notification.
|
||||
* @param buttons Provide customized notification buttons. They will be shown by default. Note that buttons can still be shown and hidden at runtime by using the functions in [NotificationManager][com.doublesymmetry.kotlinaudio.notification.NotificationManager], but they will have the default icon if not set explicitly here.
|
||||
* @param accentColor The accent color of the notification.
|
||||
* @param smallIcon The small icon of the notification which is also shown in the system status bar.
|
||||
* @param pendingIntent The [PendingIntent] that would be called when tapping on the notification itself.
|
||||
*/
|
||||
data class NotificationConfig(
|
||||
val buttons: List<NotificationButton>,
|
||||
val accentColor: Int? = null,
|
||||
@DrawableRes val smallIcon: Int? = null,
|
||||
val pendingIntent: PendingIntent? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Provide customized notification buttons. They will be shown by default. Note that buttons can still be shown and hidden at runtime by using the functions in [NotificationManager][com.doublesymmetry.kotlinaudio.notification.NotificationManager], but they will have the default icon if not set explicitly here.
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showPlayPauseButton]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showStopButton]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showRewindButton]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showRewindButtonCompact]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showForwardButton]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showForwardButtonCompact]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showNextButton]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showNextButtonCompact]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showPreviousButton]
|
||||
* @see [com.doublesymmetry.kotlinaudio.notification.NotificationManager.showPreviousButtonCompact]
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
sealed class NotificationButton {
|
||||
class PLAY_PAUSE(@DrawableRes val playIcon: Int? = null, @DrawableRes val pauseIcon: Int? = null): NotificationButton()
|
||||
class STOP(@DrawableRes val icon: Int? = null): NotificationButton()
|
||||
class FORWARD(@DrawableRes val icon: Int? = null, val isCompact: Boolean = false): NotificationButton()
|
||||
class BACKWARD(@DrawableRes val icon: Int? = null, val isCompact: Boolean = false): NotificationButton()
|
||||
class NEXT(@DrawableRes val icon: Int? = null, val isCompact: Boolean = false): NotificationButton()
|
||||
class PREVIOUS(@DrawableRes val icon: Int? = null, val isCompact: Boolean = false): NotificationButton()
|
||||
object SEEK_TO : NotificationButton()
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
import android.app.Notification
|
||||
|
||||
sealed class NotificationState {
|
||||
class POSTED(val notificationId: Int, val notification: Notification, val ongoing: Boolean) : NotificationState()
|
||||
class CANCELLED(val notificationId: Int): NotificationState()
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
data class PlayWhenReadyChangeData(val playWhenReady: Boolean, val pausedBecauseReachedEnd: Boolean)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
enum class PlaybackEndedReason {
|
||||
PLAYED_UNTIL_END, PLAYER_STOPPED, SKIPPED_TO_NEXT, SKIPPED_TO_PREVIOUS, JUMPED_TO_INDEX
|
||||
}
|
||||
vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/models/PlaybackError.kt
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
data class PlaybackError (
|
||||
val code: String? = null,
|
||||
val message: String? = null
|
||||
)
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
data class PlayerConfig(
|
||||
/**
|
||||
* Toggle whether or not a player action triggered from an outside source should be intercepted.
|
||||
*
|
||||
* The sources can be: media buttons on headphones, Android Wear, Android Auto, Google Assistant, media notification, etc.
|
||||
*
|
||||
* Setting this to true enables the use of [onPlayerActionTriggeredExternally][com.doublesymmetry.kotlinaudio.event.PlayerEventHolder.onPlayerActionTriggeredExternally] events.
|
||||
*
|
||||
* **Example**:
|
||||
* ```
|
||||
* val player = QueuedAudioPlayer(requireActivity(), playerConfig = PlayerConfig(interceptPlayerActionsTriggeredExternally = true))
|
||||
* ```
|
||||
*/
|
||||
var interceptPlayerActionsTriggeredExternally: Boolean = false,
|
||||
|
||||
/**
|
||||
* Toggle whether the player should pause automatically when audio is rerouted from a headset to device speakers.
|
||||
*/
|
||||
val handleAudioBecomingNoisy: Boolean = false,
|
||||
|
||||
/**
|
||||
* Whether audio focus should be managed automatically. See https://medium.com/google-exoplayer/easy-audio-focus-with-exoplayer-a2dcbbe4640e
|
||||
*/
|
||||
val handleAudioFocus: Boolean = false,
|
||||
/**
|
||||
* The audio content type.
|
||||
*/
|
||||
val audioContentType: AudioContentType = AudioContentType.MUSIC,
|
||||
|
||||
/**
|
||||
* The audio usage.
|
||||
*/
|
||||
val wakeMode: WakeMode = WakeMode.NONE,
|
||||
)
|
||||
vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/models/PlayerOptions.kt
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
interface PlayerOptions {
|
||||
var alwaysPauseOnInterruption: Boolean
|
||||
}
|
||||
|
||||
internal data class DefaultPlayerOptions(
|
||||
override var alwaysPauseOnInterruption: Boolean = false,
|
||||
) : PlayerOptions
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
/**
|
||||
* Use these events to track when and why the positionMs of an [AudioItem] changes.
|
||||
* Examples include changes to [AudioItem] queue, seeking, skipping, etc.
|
||||
*/
|
||||
sealed class PositionChangedReason(val oldPosition: Long, val newPosition: Long) {
|
||||
/**
|
||||
* Position has changed because the player has automatically transitioned to the next [AudioItem].
|
||||
*
|
||||
* @see [AudioItemTransitionReason]
|
||||
*/
|
||||
class AUTO(oldPosition: Long, newPosition: Long) : PositionChangedReason(oldPosition, newPosition)
|
||||
|
||||
/**
|
||||
* Position has changed because of a queue update.
|
||||
*/
|
||||
class QUEUE_CHANGED(oldPosition: Long, newPosition: Long) : PositionChangedReason(oldPosition, newPosition)
|
||||
|
||||
/**
|
||||
* Position has changed because a seek has occurred within the current [AudioItem], or another one.
|
||||
*/
|
||||
class SEEK(oldPosition: Long, newPosition: Long) : PositionChangedReason(oldPosition, newPosition)
|
||||
|
||||
/**
|
||||
* Position has changed because an attempted seek has failed. This can occur if we tried to see to an invalid positionMs.
|
||||
*/
|
||||
class SEEK_FAILED(oldPosition: Long, newPosition: Long) : PositionChangedReason(oldPosition, newPosition)
|
||||
|
||||
/**
|
||||
* Position has changed because a period (example: an ad) has been skipped.
|
||||
*/
|
||||
class SKIPPED_PERIOD(oldPosition: Long, newPosition: Long) : PositionChangedReason(oldPosition, newPosition)
|
||||
|
||||
/**
|
||||
* Position has changed for an unknown reason.
|
||||
*/
|
||||
class UNKNOWN(oldPosition: Long, newPosition: Long) : PositionChangedReason(oldPosition, newPosition)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
interface QueuedPlayerOptions : PlayerOptions {
|
||||
override var alwaysPauseOnInterruption: Boolean
|
||||
var repeatMode: RepeatMode
|
||||
}
|
||||
|
||||
class DefaultQueuedPlayerOptions(
|
||||
private val exoPlayer: ExoPlayer,
|
||||
override var alwaysPauseOnInterruption: Boolean = false,
|
||||
) : QueuedPlayerOptions {
|
||||
// Functions in data classes might or might not be a bit of a code smell.
|
||||
// I'm using the passed exoPlayer which breaks separation of concerns. But it's also useful.
|
||||
// More here: https://www.reddit.com/r/Kotlin/comments/ehqe4e/why_is_it_bad_practice_to_have_functions_in_data/
|
||||
// TODO: Figure out a way for this function to be outside of this data class
|
||||
override var repeatMode: RepeatMode
|
||||
get() {
|
||||
return when (exoPlayer.repeatMode) {
|
||||
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
|
||||
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
|
||||
else -> RepeatMode.OFF
|
||||
}
|
||||
}
|
||||
set(value) {
|
||||
when (value) {
|
||||
RepeatMode.ALL -> exoPlayer.repeatMode = Player.REPEAT_MODE_ALL
|
||||
RepeatMode.ONE -> exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
|
||||
RepeatMode.OFF -> exoPlayer.repeatMode = Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class RepeatMode {
|
||||
OFF, ONE, ALL;
|
||||
|
||||
companion object {
|
||||
fun fromOrdinal(ordinal: Int): RepeatMode {
|
||||
return when (ordinal) {
|
||||
0 -> OFF
|
||||
1 -> ONE
|
||||
2 -> ALL
|
||||
else -> error("Wrong ordinal")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
package com.doublesymmetry.kotlinaudio.models
|
||||
|
||||
enum class WakeMode {
|
||||
NONE,
|
||||
LOCAL,
|
||||
NETWORK,
|
||||
}
|
||||
+811
@@ -0,0 +1,811 @@
|
||||
package com.doublesymmetry.kotlinaudio.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.RatingCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.app.NotificationCompat
|
||||
import coil.imageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import com.doublesymmetry.kotlinaudio.R
|
||||
import com.doublesymmetry.kotlinaudio.event.NotificationEventHolder
|
||||
import com.doublesymmetry.kotlinaudio.event.PlayerEventHolder
|
||||
import com.doublesymmetry.kotlinaudio.models.AudioItem
|
||||
import com.doublesymmetry.kotlinaudio.models.MediaSessionCallback
|
||||
import com.doublesymmetry.kotlinaudio.models.NotificationButton
|
||||
import com.doublesymmetry.kotlinaudio.models.NotificationConfig
|
||||
import com.doublesymmetry.kotlinaudio.models.NotificationState
|
||||
import com.doublesymmetry.kotlinaudio.players.components.getAudioItemHolder
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager.CustomActionReceiver
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Headers.Companion.toHeaders
|
||||
|
||||
class NotificationManager internal constructor(
|
||||
private val context: Context,
|
||||
private val player: Player,
|
||||
private val mediaSession: MediaSessionCompat,
|
||||
private val mediaSessionConnector: MediaSessionConnector,
|
||||
val event: NotificationEventHolder,
|
||||
val playerEventHolder: PlayerEventHolder
|
||||
) : PlayerNotificationManager.NotificationListener {
|
||||
private var pendingIntent: PendingIntent? = null
|
||||
private val descriptionAdapter = object : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
override fun getCurrentContentTitle(player: Player): CharSequence {
|
||||
return getTitle() ?: ""
|
||||
}
|
||||
|
||||
override fun createCurrentContentIntent(player: Player): PendingIntent? {
|
||||
return pendingIntent
|
||||
}
|
||||
|
||||
override fun getCurrentContentText(player: Player): CharSequence? {
|
||||
return getArtist() ?: ""
|
||||
}
|
||||
|
||||
override fun getCurrentSubText(player: Player): CharSequence? {
|
||||
return player.mediaMetadata.displayTitle
|
||||
}
|
||||
|
||||
override fun getCurrentLargeIcon(
|
||||
player: Player,
|
||||
callback: PlayerNotificationManager.BitmapCallback,
|
||||
): Bitmap? {
|
||||
val bitmap = getCachedArtworkBitmap()
|
||||
if (bitmap != null) {
|
||||
return bitmap
|
||||
}
|
||||
val artwork = getMediaItemArtworkUrl()
|
||||
val headers = getNetworkHeaders()
|
||||
val holder = player.currentMediaItem?.getAudioItemHolder()
|
||||
if (artwork != null && holder?.artworkBitmap == null) {
|
||||
context.imageLoader.enqueue(
|
||||
ImageRequest.Builder(context)
|
||||
.data(artwork)
|
||||
.headers(headers)
|
||||
.target { result ->
|
||||
val resultBitmap = (result as BitmapDrawable).bitmap
|
||||
holder?.artworkBitmap = resultBitmap
|
||||
invalidate()
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
return iconPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
private var internalNotificationManager: PlayerNotificationManager? = null
|
||||
private val scope = MainScope()
|
||||
private val buttons = mutableSetOf<NotificationButton?>()
|
||||
private var invalidateThrottleCount = 0
|
||||
private var iconPlaceholder = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
|
||||
|
||||
private var notificationMetadataBitmap: Bitmap? = null
|
||||
private var notificationMetadataArtworkDisposable: Disposable? = null
|
||||
|
||||
/**
|
||||
* The item that should be used for the notification
|
||||
* This is used when the user manually sets the notification item
|
||||
*
|
||||
* _Note: If [BaseAudioPlayer.automaticallyUpdateNotificationMetadata] is true, this will
|
||||
* get override on a track change_
|
||||
*/
|
||||
internal var overrideAudioItem: AudioItem? = null
|
||||
set(value) {
|
||||
notificationMetadataBitmap = null
|
||||
val headers = getNetworkHeaders()
|
||||
|
||||
if (field != value) {
|
||||
if (value?.artwork != null) {
|
||||
notificationMetadataArtworkDisposable?.dispose()
|
||||
notificationMetadataArtworkDisposable = context.imageLoader.enqueue(
|
||||
ImageRequest.Builder(context)
|
||||
.data(value.artwork)
|
||||
.headers(headers)
|
||||
.target { result ->
|
||||
notificationMetadataBitmap = (result as BitmapDrawable).bitmap
|
||||
invalidate()
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun getTitle(index: Int? = null): String? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem else player.getMediaItemAt(index)
|
||||
|
||||
val audioItem = mediaItem?.getAudioItemHolder()?.audioItem
|
||||
return overrideAudioItem?.title
|
||||
?:mediaItem?.mediaMetadata?.title?.toString()
|
||||
?: audioItem?.title
|
||||
}
|
||||
|
||||
private fun getArtist(index: Int? = null): String? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem else player.getMediaItemAt(index)
|
||||
val audioItem = mediaItem?.getAudioItemHolder()?.audioItem
|
||||
|
||||
return overrideAudioItem?.artist
|
||||
?: mediaItem?.mediaMetadata?.artist?.toString()
|
||||
?: mediaItem?.mediaMetadata?.albumArtist?.toString()
|
||||
?: audioItem?.artist
|
||||
}
|
||||
|
||||
private fun getGenre(index: Int? = null): String? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem else player.getMediaItemAt(index)
|
||||
return mediaItem?.mediaMetadata?.genre?.toString()
|
||||
}
|
||||
|
||||
private fun getAlbumTitle(index: Int? = null): String? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem else player.getMediaItemAt(index)
|
||||
return mediaItem?.mediaMetadata?.albumTitle?.toString()
|
||||
?: mediaItem?.getAudioItemHolder()?.audioItem?.albumTitle
|
||||
}
|
||||
|
||||
private fun getArtworkUrl(index: Int? = null): String? {
|
||||
return getMediaItemArtworkUrl(index)
|
||||
}
|
||||
|
||||
private fun getMediaItemArtworkUrl(index: Int? = null): String? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem else player.getMediaItemAt(index)
|
||||
|
||||
return overrideAudioItem?.artwork
|
||||
?: mediaItem?.mediaMetadata?.artworkUri?.toString()
|
||||
?: mediaItem?.getAudioItemHolder()?.audioItem?.artwork
|
||||
}
|
||||
|
||||
private fun getNetworkHeaders(): Headers {
|
||||
return player.currentMediaItem?.getAudioItemHolder()?.audioItem?.options?.headers?.toHeaders() ?: Headers.Builder().build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached artwork bitmap for the current media item.
|
||||
* Bitmap might be cached if the media item has extracted one from the media file
|
||||
* or if a user is setting custom data for the notification.
|
||||
*/
|
||||
private fun getCachedArtworkBitmap(index: Int? = null): Bitmap? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem else player.getMediaItemAt(index)
|
||||
val isCurrent = index == null || index == player.currentMediaItemIndex
|
||||
val artworkData = player.mediaMetadata.artworkData
|
||||
|
||||
return if (isCurrent && overrideAudioItem != null) {
|
||||
notificationMetadataBitmap
|
||||
} else if (isCurrent && artworkData != null) {
|
||||
BitmapFactory.decodeByteArray(artworkData, 0, artworkData.size)
|
||||
} else {
|
||||
mediaItem?.getAudioItemHolder()?.artworkBitmap
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDuration(index: Int? = null): Long? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem
|
||||
else player.getMediaItemAt(index)
|
||||
|
||||
return if (player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) {
|
||||
overrideAudioItem?.duration ?: mediaItem?.getAudioItemHolder()?.audioItem?.duration ?: -1
|
||||
} else {
|
||||
overrideAudioItem?.duration ?: player.duration
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUserRating(index: Int? = null): RatingCompat? {
|
||||
val mediaItem = if (index == null) player.currentMediaItem
|
||||
else player.getMediaItemAt(index)
|
||||
return RatingCompat.fromRating(mediaItem?.mediaMetadata?.userRating)
|
||||
}
|
||||
|
||||
var showPlayPauseButton = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUsePlayPauseActions(value)
|
||||
}
|
||||
}
|
||||
|
||||
var showStopButton = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUseStopAction(value)
|
||||
}
|
||||
}
|
||||
|
||||
var showForwardButton = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUseFastForwardAction(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether or not this button should appear when the notification is compact (collapsed).
|
||||
*/
|
||||
var showForwardButtonCompact = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUseFastForwardActionInCompactView(value)
|
||||
}
|
||||
}
|
||||
|
||||
var showRewindButton = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUseRewindAction(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether or not this button should appear when the notification is compact (collapsed).
|
||||
*/
|
||||
var showRewindButtonCompact = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUseRewindActionInCompactView(value)
|
||||
}
|
||||
}
|
||||
|
||||
var showNextButton = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUseNextAction(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether or not this button should appear when the notification is compact (collapsed).
|
||||
*/
|
||||
var showNextButtonCompact = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUseNextActionInCompactView(value)
|
||||
}
|
||||
}
|
||||
|
||||
var showPreviousButton = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUsePreviousAction(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether or not this button should appear when the notification is compact (collapsed).
|
||||
*/
|
||||
var showPreviousButtonCompact = false
|
||||
set(value) {
|
||||
scope.launch {
|
||||
field = value
|
||||
internalNotificationManager?.setUsePreviousActionInCompactView(value)
|
||||
}
|
||||
}
|
||||
|
||||
var stopIcon: Int? = null
|
||||
var forwardIcon: Int? = null
|
||||
var rewindIcon: Int? = null
|
||||
|
||||
init {
|
||||
mediaSessionConnector.setQueueNavigator(
|
||||
object : TimelineQueueNavigator(mediaSession) {
|
||||
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
||||
return buttons.fold(0) { acc, button ->
|
||||
acc or when (button) {
|
||||
is NotificationButton.NEXT -> {
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||
}
|
||||
is NotificationButton.PREVIOUS -> {
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
}
|
||||
else -> {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMediaDescription(
|
||||
player: Player,
|
||||
windowIndex: Int
|
||||
): MediaDescriptionCompat {
|
||||
val title = getTitle(windowIndex)
|
||||
val artist = getArtist(windowIndex)
|
||||
return MediaDescriptionCompat.Builder().apply {
|
||||
setTitle(title)
|
||||
setSubtitle(artist)
|
||||
setExtras(Bundle().apply {
|
||||
title?.let {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, it)
|
||||
}
|
||||
artist?.let {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, it)
|
||||
}
|
||||
})
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
)
|
||||
mediaSessionConnector.setMetadataDeduplicationEnabled(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the notification metadata with the given [AudioItem].
|
||||
*
|
||||
* _Note: If [BaseAudioPlayer.automaticallyUpdateNotificationMetadata] is true, this will
|
||||
* get override on a track change._
|
||||
*/
|
||||
public fun overrideMetadata(item: AudioItem) {
|
||||
overrideAudioItem = item
|
||||
}
|
||||
|
||||
public fun getMediaMetadataCompat(): MediaMetadataCompat {
|
||||
val currentItemMetadata = player.currentMediaItem?.mediaMetadata
|
||||
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
getArtist()?.let {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, it)
|
||||
}
|
||||
getTitle()?.let {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, it)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, it)
|
||||
}
|
||||
currentItemMetadata?.subtitle?.let {
|
||||
putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, it.toString()
|
||||
)
|
||||
}
|
||||
currentItemMetadata?.description?.let {
|
||||
putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, it.toString()
|
||||
)
|
||||
}
|
||||
getAlbumTitle()?.let {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM, it)
|
||||
}
|
||||
getGenre()?.let {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_GENRE, it)
|
||||
}
|
||||
getDuration()?.let {
|
||||
putLong(MediaMetadataCompat.METADATA_KEY_DURATION, it)
|
||||
}
|
||||
getArtworkUrl()?.let {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, it)
|
||||
}
|
||||
getCachedArtworkBitmap()?.let {
|
||||
putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it);
|
||||
putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, it);
|
||||
}
|
||||
getUserRating()?.let {
|
||||
putRating(MediaMetadataCompat.METADATA_KEY_RATING, it)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun createNotificationAction(
|
||||
drawable: Int,
|
||||
action: String,
|
||||
instanceId: Int
|
||||
): NotificationCompat.Action {
|
||||
val intent: Intent = Intent(action).setPackage(context.packageName)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
instanceId,
|
||||
intent,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
|
||||
} else {
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
}
|
||||
)
|
||||
return NotificationCompat.Action.Builder(drawable, action, pendingIntent).build()
|
||||
}
|
||||
|
||||
private fun handlePlayerAction(action: String) {
|
||||
when (action) {
|
||||
REWIND -> {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.REWIND)
|
||||
}
|
||||
FORWARD -> {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.FORWARD)
|
||||
}
|
||||
STOP -> {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.STOP)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private val customActionReceiver = object : CustomActionReceiver {
|
||||
override fun createCustomActions(
|
||||
context: Context,
|
||||
instanceId: Int
|
||||
): MutableMap<String, NotificationCompat.Action> {
|
||||
if (!needsCustomActionsToAddMissingButtons) return mutableMapOf()
|
||||
return mutableMapOf(
|
||||
REWIND to createNotificationAction(
|
||||
rewindIcon ?: DEFAULT_REWIND_ICON,
|
||||
REWIND,
|
||||
instanceId
|
||||
),
|
||||
FORWARD to createNotificationAction(
|
||||
forwardIcon ?: DEFAULT_FORWARD_ICON,
|
||||
FORWARD,
|
||||
instanceId
|
||||
),
|
||||
STOP to createNotificationAction(
|
||||
stopIcon ?: DEFAULT_STOP_ICON,
|
||||
STOP,
|
||||
instanceId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getCustomActions(player: Player): List<String> {
|
||||
if (!needsCustomActionsToAddMissingButtons) return emptyList()
|
||||
return buttons.mapNotNull {
|
||||
when (it) {
|
||||
is NotificationButton.BACKWARD -> {
|
||||
REWIND
|
||||
}
|
||||
is NotificationButton.FORWARD -> {
|
||||
FORWARD
|
||||
}
|
||||
is NotificationButton.STOP -> {
|
||||
STOP
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCustomAction(player: Player, action: String, intent: Intent) {
|
||||
handlePlayerAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
if (invalidateThrottleCount++ == 0) {
|
||||
scope.launch {
|
||||
internalNotificationManager?.invalidate()
|
||||
mediaSessionConnector.invalidateMediaSessionQueue()
|
||||
mediaSessionConnector.invalidateMediaSessionMetadata()
|
||||
delay(300)
|
||||
val wasThrottled = invalidateThrottleCount > 1
|
||||
invalidateThrottleCount = 0
|
||||
if (wasThrottled) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a media player notification that automatically updates. Call this
|
||||
* method again with a different configuration to update the notification.
|
||||
*/
|
||||
fun createNotification(config: NotificationConfig) = scope.launch {
|
||||
if (isNotificationButtonsChanged(config.buttons)) {
|
||||
hideNotification()
|
||||
}
|
||||
|
||||
buttons.apply {
|
||||
clear()
|
||||
addAll(config.buttons)
|
||||
}
|
||||
|
||||
stopIcon = null
|
||||
forwardIcon = null
|
||||
rewindIcon = null
|
||||
|
||||
updateMediaSessionPlaybackActions()
|
||||
|
||||
pendingIntent = config.pendingIntent
|
||||
showPlayPauseButton = false
|
||||
showForwardButton = false
|
||||
showRewindButton = false
|
||||
showNextButton = false
|
||||
showPreviousButton = false
|
||||
showStopButton = false
|
||||
if (internalNotificationManager == null) {
|
||||
internalNotificationManager =
|
||||
PlayerNotificationManager.Builder(context, NOTIFICATION_ID, CHANNEL_ID)
|
||||
.apply {
|
||||
setChannelNameResourceId(R.string.playback_channel_name)
|
||||
setMediaDescriptionAdapter(descriptionAdapter)
|
||||
setCustomActionReceiver(customActionReceiver)
|
||||
setNotificationListener(this@NotificationManager)
|
||||
|
||||
for (button in buttons) {
|
||||
if (button == null) continue
|
||||
when (button) {
|
||||
is NotificationButton.PLAY_PAUSE -> {
|
||||
button.playIcon?.let { setPlayActionIconResourceId(it) }
|
||||
button.pauseIcon?.let { setPauseActionIconResourceId(it) }
|
||||
}
|
||||
|
||||
is NotificationButton.STOP -> button.icon?.let {
|
||||
setStopActionIconResourceId(
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
is NotificationButton.FORWARD -> button.icon?.let {
|
||||
setFastForwardActionIconResourceId(
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
is NotificationButton.BACKWARD -> button.icon?.let {
|
||||
setRewindActionIconResourceId(
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
is NotificationButton.NEXT -> button.icon?.let {
|
||||
setNextActionIconResourceId(
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
is NotificationButton.PREVIOUS -> button.icon?.let {
|
||||
setPreviousActionIconResourceId(
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}.build().apply {
|
||||
setMediaSessionToken(mediaSession.sessionToken)
|
||||
setPlayer(player)
|
||||
}
|
||||
}
|
||||
setupInternalNotificationManager(config)
|
||||
}
|
||||
|
||||
private fun isNotificationButtonsChanged(newButtons: List<NotificationButton>): Boolean {
|
||||
val currentNotificationButtonsMapByType = buttons.filterNotNull().associateBy { it::class }
|
||||
return newButtons.any { newButton ->
|
||||
when (newButton) {
|
||||
is NotificationButton.PLAY_PAUSE -> {
|
||||
(currentNotificationButtonsMapByType[NotificationButton.PLAY_PAUSE::class] as? NotificationButton.PLAY_PAUSE).let { currentButton ->
|
||||
newButton.pauseIcon != currentButton?.pauseIcon || newButton.playIcon != currentButton?.playIcon
|
||||
}
|
||||
}
|
||||
|
||||
is NotificationButton.STOP -> {
|
||||
(currentNotificationButtonsMapByType[NotificationButton.STOP::class] as? NotificationButton.STOP).let { currentButton ->
|
||||
newButton.icon != currentButton?.icon
|
||||
}
|
||||
}
|
||||
|
||||
is NotificationButton.FORWARD -> {
|
||||
(currentNotificationButtonsMapByType[NotificationButton.FORWARD::class] as? NotificationButton.FORWARD).let { currentButton ->
|
||||
newButton.icon != currentButton?.icon
|
||||
}
|
||||
}
|
||||
|
||||
is NotificationButton.BACKWARD -> {
|
||||
(currentNotificationButtonsMapByType[NotificationButton.BACKWARD::class] as? NotificationButton.BACKWARD).let { currentButton ->
|
||||
newButton.icon != currentButton?.icon
|
||||
}
|
||||
}
|
||||
|
||||
is NotificationButton.NEXT -> {
|
||||
(currentNotificationButtonsMapByType[NotificationButton.NEXT::class] as? NotificationButton.NEXT).let { currentButton ->
|
||||
newButton.icon != currentButton?.icon
|
||||
}
|
||||
}
|
||||
|
||||
is NotificationButton.PREVIOUS -> {
|
||||
(currentNotificationButtonsMapByType[NotificationButton.PREVIOUS::class] as? NotificationButton.PREVIOUS).let { currentButton ->
|
||||
newButton.icon != currentButton?.icon
|
||||
}
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMediaSessionPlaybackActions() {
|
||||
mediaSessionConnector.setEnabledPlaybackActions(
|
||||
buttons.fold(
|
||||
PlaybackStateCompat.ACTION_SET_REPEAT_MODE
|
||||
or PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE
|
||||
or PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED
|
||||
) { acc, button ->
|
||||
acc or when (button) {
|
||||
is NotificationButton.PLAY_PAUSE -> {
|
||||
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE
|
||||
}
|
||||
is NotificationButton.BACKWARD -> {
|
||||
rewindIcon = button.icon ?: rewindIcon
|
||||
PlaybackStateCompat.ACTION_REWIND
|
||||
}
|
||||
is NotificationButton.FORWARD -> {
|
||||
forwardIcon = button.icon ?: forwardIcon
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD
|
||||
}
|
||||
is NotificationButton.SEEK_TO -> {
|
||||
PlaybackStateCompat.ACTION_SEEK_TO
|
||||
}
|
||||
is NotificationButton.STOP -> {
|
||||
stopIcon = button.icon ?: stopIcon
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
}
|
||||
else -> {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (needsCustomActionsToAddMissingButtons) {
|
||||
val customActionProviders = buttons
|
||||
.sortedBy {
|
||||
when (it) {
|
||||
is NotificationButton.BACKWARD -> 1
|
||||
is NotificationButton.FORWARD -> 2
|
||||
is NotificationButton.STOP -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is NotificationButton.BACKWARD -> {
|
||||
createMediaSessionAction(rewindIcon ?: DEFAULT_REWIND_ICON, REWIND)
|
||||
}
|
||||
is NotificationButton.FORWARD -> {
|
||||
createMediaSessionAction(forwardIcon ?: DEFAULT_FORWARD_ICON, FORWARD)
|
||||
}
|
||||
is NotificationButton.STOP -> {
|
||||
createMediaSessionAction(stopIcon ?: DEFAULT_STOP_ICON, STOP)
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupInternalNotificationManager(config: NotificationConfig) {
|
||||
internalNotificationManager?.run {
|
||||
setColor(config.accentColor ?: Color.TRANSPARENT)
|
||||
config.smallIcon?.let { setSmallIcon(it) }
|
||||
for (button in buttons) {
|
||||
if (button == null) continue
|
||||
when (button) {
|
||||
is NotificationButton.PLAY_PAUSE -> {
|
||||
showPlayPauseButton = true
|
||||
}
|
||||
|
||||
is NotificationButton.STOP -> {
|
||||
showStopButton = true
|
||||
}
|
||||
|
||||
is NotificationButton.FORWARD -> {
|
||||
showForwardButton = true
|
||||
showForwardButtonCompact = button.isCompact
|
||||
}
|
||||
|
||||
is NotificationButton.BACKWARD -> {
|
||||
showRewindButton = true
|
||||
showRewindButtonCompact = button.isCompact
|
||||
}
|
||||
|
||||
is NotificationButton.NEXT -> {
|
||||
showNextButton = true
|
||||
showNextButtonCompact = button.isCompact
|
||||
}
|
||||
|
||||
is NotificationButton.PREVIOUS -> {
|
||||
showPreviousButton = true
|
||||
showPreviousButtonCompact = button.isCompact
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hideNotification() {
|
||||
internalNotificationManager?.setPlayer(null)
|
||||
internalNotificationManager = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
ongoing: Boolean
|
||||
) {
|
||||
scope.launch {
|
||||
event.updateNotificationState(
|
||||
NotificationState.POSTED(
|
||||
notificationId,
|
||||
notification,
|
||||
ongoing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
|
||||
scope.launch {
|
||||
event.updateNotificationState(NotificationState.CANCELLED(notificationId))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun destroy() = scope.launch {
|
||||
internalNotificationManager?.setPlayer(null)
|
||||
}
|
||||
|
||||
private fun createMediaSessionAction(
|
||||
@DrawableRes drawableRes: Int,
|
||||
actionName: String
|
||||
): MediaSessionConnector.CustomActionProvider {
|
||||
return object : MediaSessionConnector.CustomActionProvider {
|
||||
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||
return PlaybackStateCompat.CustomAction.Builder(actionName, actionName, drawableRes)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||
handlePlayerAction(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Due to the removal of rewind, forward, and stop buttons from the standard notification
|
||||
// controls in Android 13, custom actions are implemented to support them
|
||||
// https://developer.android.com/about/versions/13/behavior-changes-13#playback-controls
|
||||
private val needsCustomActionsToAddMissingButtons = Build.VERSION.SDK_INT >= 33
|
||||
private const val REWIND = "rewind"
|
||||
private const val FORWARD = "forward"
|
||||
private const val STOP = "stop"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val CHANNEL_ID = "kotlin_audio_player"
|
||||
private val DEFAULT_STOP_ICON =
|
||||
com.google.android.exoplayer2.ui.R.drawable.exo_notification_stop
|
||||
private val DEFAULT_REWIND_ICON =
|
||||
com.google.android.exoplayer2.ui.R.drawable.exo_notification_rewind
|
||||
private val DEFAULT_FORWARD_ICON =
|
||||
com.google.android.exoplayer2.ui.R.drawable.exo_notification_fastforward
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
package com.doublesymmetry.kotlinaudio.players
|
||||
|
||||
import android.content.Context
|
||||
import com.doublesymmetry.kotlinaudio.models.BufferConfig
|
||||
import com.doublesymmetry.kotlinaudio.models.CacheConfig
|
||||
import com.doublesymmetry.kotlinaudio.models.PlayerConfig
|
||||
|
||||
class AudioPlayer(context: Context, playerConfig: PlayerConfig = PlayerConfig(), bufferConfig: BufferConfig? = null, cacheConfig: CacheConfig? = null): BaseAudioPlayer(context, playerConfig, bufferConfig, cacheConfig)
|
||||
+764
@@ -0,0 +1,764 @@
|
||||
package com.doublesymmetry.kotlinaudio.players
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioManager.AUDIOFOCUS_LOSS
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.RatingCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media.AudioAttributesCompat
|
||||
import androidx.media.AudioAttributesCompat.CONTENT_TYPE_MUSIC
|
||||
import androidx.media.AudioAttributesCompat.USAGE_MEDIA
|
||||
import androidx.media.AudioFocusRequestCompat
|
||||
import androidx.media.AudioManagerCompat
|
||||
import androidx.media.AudioManagerCompat.AUDIOFOCUS_GAIN
|
||||
import com.doublesymmetry.kotlinaudio.event.EventHolder
|
||||
import com.doublesymmetry.kotlinaudio.event.NotificationEventHolder
|
||||
import com.doublesymmetry.kotlinaudio.event.PlayerEventHolder
|
||||
import com.doublesymmetry.kotlinaudio.models.AudioContentType
|
||||
import com.doublesymmetry.kotlinaudio.models.AudioItem
|
||||
import com.doublesymmetry.kotlinaudio.models.AudioItemHolder
|
||||
import com.doublesymmetry.kotlinaudio.models.AudioItemTransitionReason
|
||||
import com.doublesymmetry.kotlinaudio.models.AudioPlayerState
|
||||
import com.doublesymmetry.kotlinaudio.models.BufferConfig
|
||||
import com.doublesymmetry.kotlinaudio.models.CacheConfig
|
||||
import com.doublesymmetry.kotlinaudio.models.DefaultPlayerOptions
|
||||
import com.doublesymmetry.kotlinaudio.models.MediaSessionCallback
|
||||
import com.doublesymmetry.kotlinaudio.models.MediaType
|
||||
import com.doublesymmetry.kotlinaudio.models.PlayWhenReadyChangeData
|
||||
import com.doublesymmetry.kotlinaudio.models.PlaybackError
|
||||
import com.doublesymmetry.kotlinaudio.models.PlayerConfig
|
||||
import com.doublesymmetry.kotlinaudio.models.PlayerOptions
|
||||
import com.doublesymmetry.kotlinaudio.models.PositionChangedReason
|
||||
import com.doublesymmetry.kotlinaudio.models.WakeMode
|
||||
import com.doublesymmetry.kotlinaudio.notification.NotificationManager
|
||||
import com.doublesymmetry.kotlinaudio.players.components.PlayerCache
|
||||
import com.doublesymmetry.kotlinaudio.players.components.getAudioItemHolder
|
||||
import com.doublesymmetry.kotlinaudio.utils.isUriLocalFile
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.DefaultLoadControl
|
||||
import com.google.android.exoplayer2.DefaultLoadControl.Builder
|
||||
import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS
|
||||
import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS
|
||||
import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MAX_BUFFER_MS
|
||||
import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MIN_BUFFER_MS
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.ForwardingPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.Player.Listener
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.source.dash.DashMediaSource
|
||||
import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.upstream.DataSpec
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.RawResourceDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class BaseAudioPlayer internal constructor(
|
||||
internal val context: Context,
|
||||
playerConfig: PlayerConfig,
|
||||
private val bufferConfig: BufferConfig?,
|
||||
private val cacheConfig: CacheConfig?
|
||||
) : AudioManager.OnAudioFocusChangeListener {
|
||||
protected val exoPlayer: ExoPlayer
|
||||
|
||||
private var cache: SimpleCache? = null
|
||||
private val scope = MainScope()
|
||||
private var playerConfig: PlayerConfig = playerConfig
|
||||
|
||||
val notificationManager: NotificationManager
|
||||
|
||||
open val playerOptions: PlayerOptions = DefaultPlayerOptions()
|
||||
|
||||
open val currentItem: AudioItem?
|
||||
get() = exoPlayer.currentMediaItem?.getAudioItemHolder()?.audioItem
|
||||
|
||||
var playbackError: PlaybackError? = null
|
||||
var playerState: AudioPlayerState = AudioPlayerState.IDLE
|
||||
private set(value) {
|
||||
if (value != field) {
|
||||
field = value
|
||||
playerEventHolder.updateAudioPlayerState(value)
|
||||
if (!playerConfig.handleAudioFocus) {
|
||||
when (value) {
|
||||
AudioPlayerState.IDLE,
|
||||
AudioPlayerState.ERROR -> abandonAudioFocusIfHeld()
|
||||
AudioPlayerState.READY -> requestAudioFocus()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var playWhenReady: Boolean
|
||||
get() = exoPlayer.playWhenReady
|
||||
set(value) {
|
||||
exoPlayer.playWhenReady = value
|
||||
}
|
||||
|
||||
val duration: Long
|
||||
get() {
|
||||
return if (exoPlayer.duration == C.TIME_UNSET) 0
|
||||
else exoPlayer.duration
|
||||
}
|
||||
|
||||
val isCurrentMediaItemLive: Boolean
|
||||
get() = exoPlayer.isCurrentMediaItemLive
|
||||
|
||||
private var oldPosition = 0L
|
||||
|
||||
val position: Long
|
||||
get() {
|
||||
return if (exoPlayer.currentPosition == C.POSITION_UNSET.toLong()) 0
|
||||
else exoPlayer.currentPosition
|
||||
}
|
||||
|
||||
val bufferedPosition: Long
|
||||
get() {
|
||||
return if (exoPlayer.bufferedPosition == C.POSITION_UNSET.toLong()) 0
|
||||
else exoPlayer.bufferedPosition
|
||||
}
|
||||
|
||||
var volume: Float
|
||||
get() = exoPlayer.volume
|
||||
set(value) {
|
||||
exoPlayer.volume = value * volumeMultiplier
|
||||
}
|
||||
|
||||
var playbackSpeed: Float
|
||||
get() = exoPlayer.playbackParameters.speed
|
||||
set(value) {
|
||||
exoPlayer.setPlaybackSpeed(value)
|
||||
}
|
||||
|
||||
var automaticallyUpdateNotificationMetadata: Boolean = true
|
||||
|
||||
private var volumeMultiplier = 1f
|
||||
private set(value) {
|
||||
field = value
|
||||
volume = volume
|
||||
}
|
||||
|
||||
val isPlaying
|
||||
get() = exoPlayer.isPlaying
|
||||
|
||||
private val notificationEventHolder = NotificationEventHolder()
|
||||
private val playerEventHolder = PlayerEventHolder()
|
||||
|
||||
var ratingType: Int = RatingCompat.RATING_NONE
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
mediaSession.setRatingType(ratingType)
|
||||
mediaSessionConnector.setRatingCallback(object : MediaSessionConnector.RatingCallback {
|
||||
override fun onCommand(
|
||||
player: Player,
|
||||
command: String,
|
||||
extras: Bundle?,
|
||||
cb: ResultReceiver?
|
||||
): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSetRating(player: Player, rating: RatingCompat) {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(
|
||||
MediaSessionCallback.RATING(
|
||||
rating,
|
||||
null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSetRating(player: Player, rating: RatingCompat, extras: Bundle?) {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(
|
||||
MediaSessionCallback.RATING(
|
||||
rating,
|
||||
extras
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val event = EventHolder(notificationEventHolder, playerEventHolder)
|
||||
|
||||
private var focus: AudioFocusRequestCompat? = null
|
||||
private var hasAudioFocus = false
|
||||
private var wasDucking = false
|
||||
|
||||
private val mediaSession = MediaSessionCompat(context, "KotlinAudioPlayer")
|
||||
private val mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||
|
||||
init {
|
||||
if (cacheConfig != null) {
|
||||
cache = PlayerCache.getInstance(context, cacheConfig)
|
||||
}
|
||||
|
||||
exoPlayer = ExoPlayer.Builder(context)
|
||||
// ASTRA M3: insert the pre-EQ PCM tap into the audio sink's processor
|
||||
// chain. This is the ONLY change vs upstream kotlin-audio v2.1.0.
|
||||
.setRenderersFactory(
|
||||
com.doublesymmetry.kotlinaudio.scope.buildScopeRenderersFactory(context)
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(playerConfig.handleAudioBecomingNoisy)
|
||||
.setWakeMode(
|
||||
when (playerConfig.wakeMode) {
|
||||
WakeMode.NONE -> C.WAKE_MODE_NONE
|
||||
WakeMode.LOCAL -> C.WAKE_MODE_LOCAL
|
||||
WakeMode.NETWORK -> C.WAKE_MODE_NETWORK
|
||||
}
|
||||
)
|
||||
.apply {
|
||||
if (bufferConfig != null) setLoadControl(setupBuffer(bufferConfig))
|
||||
}
|
||||
.build()
|
||||
|
||||
mediaSession.isActive = true
|
||||
|
||||
val playerToUse =
|
||||
if (playerConfig.interceptPlayerActionsTriggeredExternally) createForwardingPlayer() else exoPlayer
|
||||
|
||||
notificationManager = NotificationManager(
|
||||
context,
|
||||
playerToUse,
|
||||
mediaSession,
|
||||
mediaSessionConnector,
|
||||
notificationEventHolder,
|
||||
playerEventHolder
|
||||
)
|
||||
|
||||
exoPlayer.addListener(PlayerListener())
|
||||
|
||||
scope.launch {
|
||||
// Whether ExoPlayer should manage audio focus for us automatically
|
||||
// see https://medium.com/google-exoplayer/easy-audio-focus-with-exoplayer-a2dcbbe4640e
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(
|
||||
when (playerConfig.audioContentType) {
|
||||
AudioContentType.MUSIC -> C.AUDIO_CONTENT_TYPE_MUSIC
|
||||
AudioContentType.SPEECH -> C.AUDIO_CONTENT_TYPE_SPEECH
|
||||
AudioContentType.SONIFICATION -> C.AUDIO_CONTENT_TYPE_SONIFICATION
|
||||
AudioContentType.MOVIE -> C.AUDIO_CONTENT_TYPE_MOVIE
|
||||
AudioContentType.UNKNOWN -> C.AUDIO_CONTENT_TYPE_UNKNOWN
|
||||
}
|
||||
)
|
||||
.build();
|
||||
exoPlayer.setAudioAttributes(audioAttributes, playerConfig.handleAudioFocus);
|
||||
mediaSessionConnector.setPlayer(playerToUse)
|
||||
mediaSessionConnector.setMediaMetadataProvider {
|
||||
notificationManager.getMediaMetadataCompat()
|
||||
}
|
||||
}
|
||||
|
||||
playerEventHolder.updateAudioPlayerState(AudioPlayerState.IDLE)
|
||||
}
|
||||
|
||||
private fun createForwardingPlayer(): ForwardingPlayer {
|
||||
return object : ForwardingPlayer(exoPlayer) {
|
||||
override fun play() {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.PLAY)
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.PAUSE)
|
||||
}
|
||||
|
||||
override fun seekToNext() {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.NEXT)
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.PREVIOUS)
|
||||
}
|
||||
|
||||
override fun seekForward() {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.FORWARD)
|
||||
}
|
||||
|
||||
override fun seekBack() {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.REWIND)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(MediaSessionCallback.STOP)
|
||||
}
|
||||
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(
|
||||
MediaSessionCallback.SEEK(
|
||||
positionMs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
playerEventHolder.updateOnPlayerActionTriggeredExternally(
|
||||
MediaSessionCallback.SEEK(
|
||||
positionMs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateNotificationIfNecessary(overrideAudioItem: AudioItem? = null) {
|
||||
if (automaticallyUpdateNotificationMetadata) {
|
||||
notificationManager.overrideAudioItem = overrideAudioItem
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBuffer(bufferConfig: BufferConfig): DefaultLoadControl {
|
||||
bufferConfig.apply {
|
||||
val multiplier =
|
||||
DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS / DEFAULT_BUFFER_FOR_PLAYBACK_MS
|
||||
val minBuffer =
|
||||
if (minBuffer != null && minBuffer != 0) minBuffer else DEFAULT_MIN_BUFFER_MS
|
||||
val maxBuffer =
|
||||
if (maxBuffer != null && maxBuffer != 0) maxBuffer else DEFAULT_MAX_BUFFER_MS
|
||||
val playBuffer =
|
||||
if (playBuffer != null && playBuffer != 0) playBuffer else DEFAULT_BUFFER_FOR_PLAYBACK_MS
|
||||
val backBuffer =
|
||||
if (backBuffer != null && backBuffer != 0) backBuffer else DEFAULT_BACK_BUFFER_DURATION_MS
|
||||
|
||||
return Builder()
|
||||
.setBufferDurationsMs(minBuffer, maxBuffer, playBuffer, playBuffer * multiplier)
|
||||
.setBackBuffer(backBuffer, false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will replace the current item with a new one and load it into the player.
|
||||
* @param item The [AudioItem] to replace the current one.
|
||||
* @param playWhenReady Whether playback starts automatically.
|
||||
*/
|
||||
open fun load(item: AudioItem, playWhenReady: Boolean = true) {
|
||||
exoPlayer.playWhenReady = playWhenReady
|
||||
load(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Will replace the current item with a new one and load it into the player.
|
||||
* @param item The [AudioItem] to replace the current one.
|
||||
*/
|
||||
open fun load(item: AudioItem) {
|
||||
val mediaSource = getMediaSourceFromAudioItem(item)
|
||||
exoPlayer.addMediaSource(mediaSource)
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
fun togglePlaying() {
|
||||
if (exoPlayer.isPlaying) {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
var skipSilence: Boolean
|
||||
get() = exoPlayer.skipSilenceEnabled
|
||||
set(value) {
|
||||
exoPlayer.skipSilenceEnabled = value;
|
||||
}
|
||||
|
||||
fun play() {
|
||||
exoPlayer.play()
|
||||
if (currentItem != null) {
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare() {
|
||||
if (currentItem != null) {
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops playback, without clearing the active item. Calling this method will cause the playback
|
||||
* state to transition to AudioPlayerState.IDLE and the player will release the loaded media and
|
||||
* resources required for playback.
|
||||
*/
|
||||
@CallSuper
|
||||
open fun stop() {
|
||||
playerState = AudioPlayerState.STOPPED
|
||||
exoPlayer.playWhenReady = false
|
||||
exoPlayer.stop()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun clear() {
|
||||
exoPlayer.clearMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback whenever an item plays to its end.
|
||||
*/
|
||||
fun setPauseAtEndOfItem(pause: Boolean) {
|
||||
exoPlayer.pauseAtEndOfMediaItems = pause
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops and destroys the player. Only call this when you are finished using the player, otherwise use [pause].
|
||||
*/
|
||||
@CallSuper
|
||||
open fun destroy() {
|
||||
abandonAudioFocusIfHeld()
|
||||
stop()
|
||||
notificationManager.destroy()
|
||||
exoPlayer.release()
|
||||
cache?.release()
|
||||
cache = null
|
||||
mediaSession.isActive = false
|
||||
}
|
||||
|
||||
open fun seek(duration: Long, unit: TimeUnit) {
|
||||
val positionMs = TimeUnit.MILLISECONDS.convert(duration, unit)
|
||||
exoPlayer.seekTo(positionMs)
|
||||
}
|
||||
|
||||
open fun seekBy(offset: Long, unit: TimeUnit) {
|
||||
val positionMs = exoPlayer.currentPosition + TimeUnit.MILLISECONDS.convert(offset, unit)
|
||||
exoPlayer.seekTo(positionMs)
|
||||
}
|
||||
|
||||
protected fun getMediaSourceFromAudioItem(audioItem: AudioItem): MediaSource {
|
||||
val uri = Uri.parse(audioItem.audioUrl)
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(audioItem.audioUrl)
|
||||
.setTag(AudioItemHolder(audioItem))
|
||||
.build()
|
||||
|
||||
val userAgent =
|
||||
if (audioItem.options == null || audioItem.options!!.userAgent.isNullOrBlank()) {
|
||||
Util.getUserAgent(context, APPLICATION_NAME)
|
||||
} else {
|
||||
audioItem.options!!.userAgent
|
||||
}
|
||||
|
||||
val factory: DataSource.Factory = when {
|
||||
audioItem.options?.resourceId != null -> {
|
||||
val raw = RawResourceDataSource(context)
|
||||
raw.open(DataSpec(uri))
|
||||
DataSource.Factory { raw }
|
||||
}
|
||||
isUriLocalFile(uri) -> {
|
||||
DefaultDataSourceFactory(context, userAgent)
|
||||
}
|
||||
else -> {
|
||||
val tempFactory = DefaultHttpDataSource.Factory().apply {
|
||||
setUserAgent(userAgent)
|
||||
setAllowCrossProtocolRedirects(true)
|
||||
|
||||
audioItem.options?.headers?.let {
|
||||
setDefaultRequestProperties(it.toMap())
|
||||
}
|
||||
}
|
||||
|
||||
enableCaching(tempFactory)
|
||||
}
|
||||
}
|
||||
|
||||
return when (audioItem.type) {
|
||||
MediaType.DASH -> createDashSource(mediaItem, factory)
|
||||
MediaType.HLS -> createHlsSource(mediaItem, factory)
|
||||
MediaType.SMOOTH_STREAMING -> createSsSource(mediaItem, factory)
|
||||
else -> createProgressiveSource(mediaItem, factory)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDashSource(mediaItem: MediaItem, factory: DataSource.Factory?): MediaSource {
|
||||
return DashMediaSource.Factory(DefaultDashChunkSource.Factory(factory!!), factory)
|
||||
.createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
private fun createHlsSource(mediaItem: MediaItem, factory: DataSource.Factory?): MediaSource {
|
||||
return HlsMediaSource.Factory(factory!!)
|
||||
.createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
private fun createSsSource(mediaItem: MediaItem, factory: DataSource.Factory?): MediaSource {
|
||||
return SsMediaSource.Factory(DefaultSsChunkSource.Factory(factory!!), factory)
|
||||
.createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
private fun createProgressiveSource(
|
||||
mediaItem: MediaItem,
|
||||
factory: DataSource.Factory
|
||||
): ProgressiveMediaSource {
|
||||
return ProgressiveMediaSource.Factory(
|
||||
factory, DefaultExtractorsFactory()
|
||||
.setConstantBitrateSeekingEnabled(true)
|
||||
)
|
||||
.createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
private fun enableCaching(factory: DataSource.Factory): DataSource.Factory {
|
||||
return if (cache == null || cacheConfig == null || (cacheConfig.maxCacheSize ?: 0) <= 0) {
|
||||
factory
|
||||
} else {
|
||||
CacheDataSource.Factory().apply {
|
||||
setCache(this@BaseAudioPlayer.cache!!)
|
||||
setUpstreamDataSourceFactory(factory)
|
||||
setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
if (hasAudioFocus) return
|
||||
Timber.d("Requesting audio focus...")
|
||||
|
||||
val manager = ContextCompat.getSystemService(context, AudioManager::class.java)
|
||||
|
||||
focus = AudioFocusRequestCompat.Builder(AUDIOFOCUS_GAIN)
|
||||
.setOnAudioFocusChangeListener(this)
|
||||
.setAudioAttributes(
|
||||
AudioAttributesCompat.Builder()
|
||||
.setUsage(USAGE_MEDIA)
|
||||
.setContentType(CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
)
|
||||
.setWillPauseWhenDucked(playerOptions.alwaysPauseOnInterruption)
|
||||
.build()
|
||||
|
||||
val result: Int = if (manager != null && focus != null) {
|
||||
AudioManagerCompat.requestAudioFocus(manager, focus!!)
|
||||
} else {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_FAILED
|
||||
}
|
||||
|
||||
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||
}
|
||||
|
||||
private fun abandonAudioFocusIfHeld() {
|
||||
if (!hasAudioFocus) return
|
||||
Timber.d("Abandoning audio focus...")
|
||||
|
||||
val manager = ContextCompat.getSystemService(context, AudioManager::class.java)
|
||||
|
||||
val result: Int = if (manager != null && focus != null) {
|
||||
AudioManagerCompat.abandonAudioFocusRequest(manager, focus!!)
|
||||
} else {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_FAILED
|
||||
}
|
||||
|
||||
hasAudioFocus = (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||
}
|
||||
|
||||
override fun onAudioFocusChange(focusChange: Int) {
|
||||
Timber.d("Audio focus changed")
|
||||
val isPermanent = focusChange == AUDIOFOCUS_LOSS
|
||||
val isPaused = when (focusChange) {
|
||||
AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> true
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> playerOptions.alwaysPauseOnInterruption
|
||||
else -> false
|
||||
}
|
||||
if (!playerConfig.handleAudioFocus) {
|
||||
if (isPermanent) abandonAudioFocusIfHeld()
|
||||
|
||||
val isDucking = focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
|
||||
&& !playerOptions.alwaysPauseOnInterruption
|
||||
if (isDucking) {
|
||||
volumeMultiplier = 0.5f
|
||||
wasDucking = true
|
||||
} else if (wasDucking) {
|
||||
volumeMultiplier = 1f
|
||||
wasDucking = false
|
||||
}
|
||||
}
|
||||
|
||||
playerEventHolder.updateOnAudioFocusChanged(isPaused, isPermanent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APPLICATION_NAME = "react-native-track-player"
|
||||
}
|
||||
|
||||
inner class PlayerListener : Listener {
|
||||
/**
|
||||
* Called when there is metadata associated with the current playback time.
|
||||
*/
|
||||
override fun onMetadata(metadata: Metadata) {
|
||||
playerEventHolder.updateOnTimedMetadata(metadata)
|
||||
}
|
||||
|
||||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||
playerEventHolder.updateOnCommonMetadata(mediaMetadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* A position discontinuity occurs when the playing period changes, the playback position
|
||||
* jumps within the period currently being played, or when the playing period has been
|
||||
* skipped or removed.
|
||||
*/
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
this@BaseAudioPlayer.oldPosition = oldPosition.positionMs
|
||||
|
||||
when (reason) {
|
||||
Player.DISCONTINUITY_REASON_AUTO_TRANSITION -> playerEventHolder.updatePositionChangedReason(
|
||||
PositionChangedReason.AUTO(oldPosition.positionMs, newPosition.positionMs)
|
||||
)
|
||||
Player.DISCONTINUITY_REASON_SEEK -> playerEventHolder.updatePositionChangedReason(
|
||||
PositionChangedReason.SEEK(oldPosition.positionMs, newPosition.positionMs)
|
||||
)
|
||||
Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT -> playerEventHolder.updatePositionChangedReason(
|
||||
PositionChangedReason.SEEK_FAILED(
|
||||
oldPosition.positionMs,
|
||||
newPosition.positionMs
|
||||
)
|
||||
)
|
||||
Player.DISCONTINUITY_REASON_REMOVE -> playerEventHolder.updatePositionChangedReason(
|
||||
PositionChangedReason.QUEUE_CHANGED(
|
||||
oldPosition.positionMs,
|
||||
newPosition.positionMs
|
||||
)
|
||||
)
|
||||
Player.DISCONTINUITY_REASON_SKIP -> playerEventHolder.updatePositionChangedReason(
|
||||
PositionChangedReason.SKIPPED_PERIOD(
|
||||
oldPosition.positionMs,
|
||||
newPosition.positionMs
|
||||
)
|
||||
)
|
||||
Player.DISCONTINUITY_REASON_INTERNAL -> playerEventHolder.updatePositionChangedReason(
|
||||
PositionChangedReason.UNKNOWN(oldPosition.positionMs, newPosition.positionMs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when playback transitions to a media item or starts repeating a media item
|
||||
* according to the current repeat mode. Note that this callback is also called when the
|
||||
* playlist becomes non-empty or empty as a consequence of a playlist change.
|
||||
*/
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
when (reason) {
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> playerEventHolder.updateAudioItemTransition(
|
||||
AudioItemTransitionReason.AUTO(oldPosition)
|
||||
)
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> playerEventHolder.updateAudioItemTransition(
|
||||
AudioItemTransitionReason.QUEUE_CHANGED(oldPosition)
|
||||
)
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> playerEventHolder.updateAudioItemTransition(
|
||||
AudioItemTransitionReason.REPEAT(oldPosition)
|
||||
)
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> playerEventHolder.updateAudioItemTransition(
|
||||
AudioItemTransitionReason.SEEK_TO_ANOTHER_AUDIO_ITEM(oldPosition)
|
||||
)
|
||||
}
|
||||
|
||||
updateNotificationIfNecessary()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the value returned from Player.getPlayWhenReady() changes.
|
||||
*/
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
val pausedBecauseReachedEnd = reason == Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM
|
||||
playerEventHolder.updatePlayWhenReadyChange(PlayWhenReadyChangeData(playWhenReady, pausedBecauseReachedEnd))
|
||||
}
|
||||
|
||||
/**
|
||||
* The generic onEvents callback provides access to the Player object and specifies the set
|
||||
* of events that occurred together. It’s always called after the callbacks that correspond
|
||||
* to the individual events.
|
||||
*/
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
// Note that it is necessary to set `playerState` in order, since each mutation fires an
|
||||
// event.
|
||||
for (i in 0 until events.size()) {
|
||||
when (events[i]) {
|
||||
Player.EVENT_PLAYBACK_STATE_CHANGED -> {
|
||||
val state = when (player.playbackState) {
|
||||
Player.STATE_BUFFERING -> AudioPlayerState.BUFFERING
|
||||
Player.STATE_READY -> AudioPlayerState.READY
|
||||
Player.STATE_IDLE ->
|
||||
// Avoid transitioning to idle from error or stopped
|
||||
if (
|
||||
playerState == AudioPlayerState.ERROR ||
|
||||
playerState == AudioPlayerState.STOPPED
|
||||
)
|
||||
null
|
||||
else
|
||||
AudioPlayerState.IDLE
|
||||
Player.STATE_ENDED ->
|
||||
if (player.mediaItemCount > 0) AudioPlayerState.ENDED
|
||||
else AudioPlayerState.IDLE
|
||||
else -> null // noop
|
||||
}
|
||||
if (state != null && state != playerState) {
|
||||
playerState = state
|
||||
}
|
||||
}
|
||||
Player.EVENT_MEDIA_ITEM_TRANSITION -> {
|
||||
playbackError = null
|
||||
if (currentItem != null) {
|
||||
playerState = AudioPlayerState.LOADING
|
||||
if (isPlaying) {
|
||||
playerState = AudioPlayerState.READY
|
||||
playerState = AudioPlayerState.PLAYING
|
||||
}
|
||||
}
|
||||
}
|
||||
Player.EVENT_PLAY_WHEN_READY_CHANGED -> {
|
||||
if (!player.playWhenReady && playerState != AudioPlayerState.STOPPED) {
|
||||
playerState = AudioPlayerState.PAUSED
|
||||
}
|
||||
}
|
||||
Player.EVENT_IS_PLAYING_CHANGED -> {
|
||||
if (player.isPlaying) {
|
||||
playerState = AudioPlayerState.PLAYING
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
val _playbackError = PlaybackError(
|
||||
error.errorCodeName
|
||||
.replace("ERROR_CODE_", "")
|
||||
.lowercase(Locale.getDefault())
|
||||
.replace("_", "-"),
|
||||
error.message
|
||||
)
|
||||
playerEventHolder.updatePlaybackError(_playbackError)
|
||||
playbackError = _playbackError
|
||||
playerState = AudioPlayerState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package com.doublesymmetry.kotlinaudio.players
|
||||
|
||||
import android.content.Context
|
||||
import com.doublesymmetry.kotlinaudio.models.*
|
||||
import com.doublesymmetry.kotlinaudio.players.components.getAudioItemHolder
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.IllegalSeekPositionException
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class QueuedAudioPlayer(
|
||||
context: Context,
|
||||
playerConfig: PlayerConfig = PlayerConfig(),
|
||||
bufferConfig: BufferConfig? = null,
|
||||
cacheConfig: CacheConfig? = null
|
||||
) : BaseAudioPlayer(context, playerConfig, bufferConfig, cacheConfig) {
|
||||
private val queue = LinkedList<MediaSource>()
|
||||
override val playerOptions = DefaultQueuedPlayerOptions(exoPlayer)
|
||||
|
||||
val currentIndex
|
||||
get() = exoPlayer.currentMediaItemIndex
|
||||
|
||||
override val currentItem: AudioItem?
|
||||
get() = queue.getOrNull(currentIndex)?.mediaItem?.getAudioItemHolder()?.audioItem
|
||||
|
||||
val nextIndex: Int?
|
||||
get() {
|
||||
return if (exoPlayer.nextMediaItemIndex == C.INDEX_UNSET) null
|
||||
else exoPlayer.nextMediaItemIndex
|
||||
}
|
||||
|
||||
val previousIndex: Int?
|
||||
get() {
|
||||
return if (exoPlayer.previousMediaItemIndex == C.INDEX_UNSET) null
|
||||
else exoPlayer.previousMediaItemIndex
|
||||
}
|
||||
|
||||
val items: List<AudioItem>
|
||||
get() = queue.map { it.mediaItem.getAudioItemHolder().audioItem }
|
||||
|
||||
val previousItems: List<AudioItem>
|
||||
get() {
|
||||
return if (queue.isEmpty()) emptyList()
|
||||
else queue
|
||||
.subList(0, exoPlayer.currentMediaItemIndex)
|
||||
.map { it.mediaItem.getAudioItemHolder().audioItem }
|
||||
}
|
||||
|
||||
val nextItems: List<AudioItem>
|
||||
get() {
|
||||
return if (queue.isEmpty()) emptyList()
|
||||
else queue
|
||||
.subList(exoPlayer.currentMediaItemIndex, queue.lastIndex)
|
||||
.map { it.mediaItem.getAudioItemHolder().audioItem }
|
||||
}
|
||||
|
||||
val nextItem: AudioItem?
|
||||
get() = items.getOrNull(currentIndex + 1)
|
||||
|
||||
val previousItem: AudioItem?
|
||||
get() = items.getOrNull(currentIndex - 1)
|
||||
|
||||
override fun load(item: AudioItem, playWhenReady: Boolean) {
|
||||
load(item)
|
||||
exoPlayer.playWhenReady = playWhenReady
|
||||
}
|
||||
|
||||
override fun load(item: AudioItem) {
|
||||
if (queue.isEmpty()) {
|
||||
add(item)
|
||||
} else {
|
||||
val mediaSource = getMediaSourceFromAudioItem(item)
|
||||
queue[currentIndex] = mediaSource
|
||||
exoPlayer.addMediaSource(currentIndex + 1, mediaSource)
|
||||
exoPlayer.removeMediaItem(currentIndex)
|
||||
exoPlayer.seekTo(currentIndex, C.TIME_UNSET);
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single item to the queue. If the AudioPlayer has no item loaded, it will load the `item`.
|
||||
* @param item The [AudioItem] to add.
|
||||
*/
|
||||
fun add(item: AudioItem, playWhenReady: Boolean) {
|
||||
exoPlayer.playWhenReady = playWhenReady
|
||||
add(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single item to the queue. If the AudioPlayer has no item loaded, it will load the `item`.
|
||||
* @param item The [AudioItem] to add.
|
||||
* @param playWhenReady Whether playback starts automatically.
|
||||
*/
|
||||
fun add(item: AudioItem) {
|
||||
val mediaSource = getMediaSourceFromAudioItem(item)
|
||||
queue.add(mediaSource)
|
||||
exoPlayer.addMediaSource(mediaSource)
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple items to the queue. If the AudioPlayer has no item loaded, it will load the first item in the list.
|
||||
* @param items The [AudioItem]s to add.
|
||||
* @param playWhenReady Whether playback starts automatically.
|
||||
*/
|
||||
fun add(items: List<AudioItem>, playWhenReady: Boolean) {
|
||||
exoPlayer.playWhenReady = playWhenReady
|
||||
add(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple items to the queue. If the AudioPlayer has no item loaded, it will load the first item in the list.
|
||||
* @param items The [AudioItem]s to add.
|
||||
*/
|
||||
fun add(items: List<AudioItem>) {
|
||||
val mediaSources = items.map { getMediaSourceFromAudioItem(it) }
|
||||
queue.addAll(mediaSources)
|
||||
exoPlayer.addMediaSources(mediaSources)
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add multiple items to the queue.
|
||||
* @param items The [AudioItem]s to add.
|
||||
* @param atIndex Index to insert items at, if no items loaded this will not automatically start playback.
|
||||
*/
|
||||
fun add(items: List<AudioItem>, atIndex: Int) {
|
||||
val mediaSources = items.map { getMediaSourceFromAudioItem(it) }
|
||||
queue.addAll(atIndex, mediaSources)
|
||||
exoPlayer.addMediaSources(atIndex, mediaSources)
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the queue.
|
||||
* @param index The index of the item to remove.
|
||||
*/
|
||||
fun remove(index: Int) {
|
||||
queue.removeAt(index)
|
||||
exoPlayer.removeMediaItem(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove items from the queue.
|
||||
* @param indexes The indexes of the items to remove.
|
||||
*/
|
||||
fun remove(indexes: List<Int>) {
|
||||
val sorted = indexes.toMutableList()
|
||||
// Sort the indexes in descending order so we can safely remove them one by one
|
||||
// without having the next index possibly newly pointing to another item than intended:
|
||||
sorted.sortDescending()
|
||||
sorted.forEach {
|
||||
remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip to the next item in the queue, which may depend on the current repeat mode.
|
||||
* Does nothing if there is no next item to skip to.
|
||||
*/
|
||||
fun next() {
|
||||
exoPlayer.seekToNextMediaItem()
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip to the previous item in the queue, which may depend on the current repeat mode.
|
||||
* Does nothing if there is no previous item to skip to.
|
||||
*/
|
||||
fun previous() {
|
||||
exoPlayer.seekToPreviousMediaItem()
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item in the queue from one position to another.
|
||||
* @param fromIndex The index of the item ot move.
|
||||
* @param toIndex The index to move the item to. If the index is larger than the size of the queue, the item is moved to the end of the queue instead.
|
||||
*/
|
||||
fun move(fromIndex: Int, toIndex: Int) {
|
||||
exoPlayer.moveMediaItem(fromIndex, toIndex)
|
||||
val item = queue[fromIndex]
|
||||
queue.removeAt(fromIndex)
|
||||
queue.add(max(0, min(items.size, if (toIndex > fromIndex) toIndex else toIndex - 1)), item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to an item in the queue.
|
||||
* @param index the index to jump to
|
||||
* @param playWhenReady Whether playback starts automatically.
|
||||
*/
|
||||
fun jumpToItem(index: Int, playWhenReady: Boolean) {
|
||||
exoPlayer.playWhenReady = playWhenReady
|
||||
jumpToItem(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to an item in the queue.
|
||||
* @param index the index to jump to
|
||||
*/
|
||||
fun jumpToItem(index: Int) {
|
||||
try {
|
||||
exoPlayer.seekTo(index, C.TIME_UNSET)
|
||||
exoPlayer.prepare()
|
||||
} catch (e: IllegalSeekPositionException) {
|
||||
throw Error("This item index $index does not exist. The size of the queue is ${queue.size} items.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces item at index in queue.
|
||||
* If updating current index, we update the notification metadata if [automaticallyUpdateNotificationMetadata] is true.
|
||||
*/
|
||||
fun replaceItem(index: Int, item: AudioItem) {
|
||||
val mediaSource = getMediaSourceFromAudioItem(item)
|
||||
queue[index] = mediaSource
|
||||
if (index == currentIndex) {
|
||||
updateNotificationIfNecessary(overrideAudioItem = item)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the upcoming items, if any (the ones returned by [next]).
|
||||
*/
|
||||
fun removeUpcomingItems() {
|
||||
if (queue.lastIndex == -1 || currentIndex == -1) return
|
||||
val lastIndex = queue.lastIndex + 1
|
||||
val fromIndex = currentIndex + 1
|
||||
|
||||
exoPlayer.removeMediaItems(fromIndex, lastIndex)
|
||||
queue.subList(fromIndex, lastIndex).clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the previous items, if any (the ones returned by [previous]).
|
||||
*/
|
||||
fun removePreviousItems() {
|
||||
exoPlayer.removeMediaItems(0, currentIndex)
|
||||
queue.subList(0, currentIndex).clear()
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
queue.clear()
|
||||
super.destroy()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
queue.clear()
|
||||
super.clear()
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.doublesymmetry.kotlinaudio.players.components
|
||||
|
||||
import com.doublesymmetry.kotlinaudio.models.AudioItemHolder
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
|
||||
fun MediaItem.getAudioItemHolder(): AudioItemHolder {
|
||||
return localConfiguration!!.tag as AudioItemHolder
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.doublesymmetry.kotlinaudio.players.components
|
||||
|
||||
import android.content.Context
|
||||
import com.doublesymmetry.kotlinaudio.models.CacheConfig
|
||||
import com.google.android.exoplayer2.database.DatabaseProvider
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import java.io.File
|
||||
|
||||
object PlayerCache {
|
||||
@Volatile
|
||||
private var instance: SimpleCache? = null
|
||||
|
||||
fun getInstance(context: Context, cacheConfig: CacheConfig): SimpleCache? {
|
||||
val cacheDir = File(context.cacheDir, cacheConfig.identifier)
|
||||
val db: DatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: SimpleCache(cacheDir, LeastRecentlyUsedCacheEvictor(cacheConfig.maxCacheSize ?: 0), db)
|
||||
.also { instance = it }
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.doublesymmetry.kotlinaudio.scope
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory
|
||||
import com.google.android.exoplayer2.audio.AudioProcessor
|
||||
import com.google.android.exoplayer2.audio.AudioSink
|
||||
import com.google.android.exoplayer2.audio.DefaultAudioSink
|
||||
|
||||
/**
|
||||
* A DefaultRenderersFactory whose audio sink runs our pre-EQ PCM tap as the
|
||||
* first (and, for M3, only) AudioProcessor. Float-output / playback-param
|
||||
* capabilities are preserved by forwarding the flags. M4 will prepend the EQ
|
||||
* AudioProcessor (and add a second post-EQ tap) to this same chain.
|
||||
*/
|
||||
fun buildScopeRenderersFactory(context: Context): DefaultRenderersFactory =
|
||||
object : DefaultRenderersFactory(context) {
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioTrackPlaybackParams: Boolean,
|
||||
enableOffload: Boolean
|
||||
): AudioSink =
|
||||
DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
|
||||
.setAudioProcessors(arrayOf<AudioProcessor>(ScopeTapAudioProcessor()))
|
||||
.build()
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.doublesymmetry.kotlinaudio.scope
|
||||
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.audio.AudioProcessor
|
||||
import com.google.android.exoplayer2.audio.BaseAudioProcessor
|
||||
import expo.modules.astrascope.ScopeBridge
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Pass-through ExoPlayer AudioProcessor (M3 pre-EQ tap). Output == input so the
|
||||
* audio path stays bit-exact; when the scope is active it also copies a mono
|
||||
* downmix-ready interleaved float frame to the native analyzer via ScopeBridge.
|
||||
* Allocation-free in steady state (reuses a scratch buffer); the ScopeBridge.active
|
||||
* gate keeps a backgrounded/paused app's audio callback at ~zero cost.
|
||||
*/
|
||||
class ScopeTapAudioProcessor : BaseAudioProcessor() {
|
||||
private var scratch = FloatArray(0)
|
||||
|
||||
override fun onConfigure(
|
||||
inputAudioFormat: AudioProcessor.AudioFormat
|
||||
): AudioProcessor.AudioFormat {
|
||||
if (inputAudioFormat.encoding == C.ENCODING_PCM_16BIT ||
|
||||
inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT
|
||||
) {
|
||||
ScopeBridge.nativeConfigure(inputAudioFormat.sampleRate, inputAudioFormat.channelCount)
|
||||
}
|
||||
// Always pass the format through unchanged.
|
||||
return inputAudioFormat
|
||||
}
|
||||
|
||||
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||
val remaining = inputBuffer.remaining()
|
||||
if (remaining <= 0) return
|
||||
|
||||
if (ScopeBridge.active) {
|
||||
tap(inputBuffer)
|
||||
}
|
||||
|
||||
// Forward the audio unchanged (reads inputBuffer's own position/limit).
|
||||
val out = replaceOutputBuffer(remaining)
|
||||
out.put(inputBuffer)
|
||||
out.flip()
|
||||
}
|
||||
|
||||
// Reads from a duplicate so inputBuffer's position is left intact for forwarding.
|
||||
private fun tap(inputBuffer: ByteBuffer) {
|
||||
val channels = inputAudioFormat.channelCount
|
||||
if (channels <= 0) return
|
||||
val dup = inputBuffer.duplicate().order(ByteOrder.nativeOrder())
|
||||
|
||||
when (inputAudioFormat.encoding) {
|
||||
C.ENCODING_PCM_FLOAT -> {
|
||||
val fb = dup.asFloatBuffer()
|
||||
val n = fb.remaining()
|
||||
if (n <= 0) return
|
||||
if (scratch.size < n) scratch = FloatArray(n)
|
||||
fb.get(scratch, 0, n)
|
||||
ScopeBridge.nativePushFrames(scratch, n / channels, channels)
|
||||
}
|
||||
C.ENCODING_PCM_16BIT -> {
|
||||
val sb = dup.asShortBuffer()
|
||||
val n = sb.remaining()
|
||||
if (n <= 0) return
|
||||
if (scratch.size < n) scratch = FloatArray(n)
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
scratch[i] = sb.get(i) / 32768f
|
||||
i++
|
||||
}
|
||||
ScopeBridge.nativePushFrames(scratch, n / channels, channels)
|
||||
}
|
||||
else -> { /* unsupported PCM encoding — forward only */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
package com.doublesymmetry.kotlinaudio.utils
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import com.google.android.exoplayer2.upstream.RawResourceDataSource
|
||||
|
||||
fun isUriLocalFile(uri: Uri?): Boolean {
|
||||
if (uri == null) return false
|
||||
val scheme = uri.scheme
|
||||
val host = uri.host
|
||||
if((scheme == "http" || scheme == "https") && (host == "localhost" || host == "127.0.0.1" || host == "[::1]"))
|
||||
{
|
||||
return false
|
||||
}
|
||||
return scheme == null || scheme == ContentResolver.SCHEME_FILE || scheme == ContentResolver.SCHEME_ANDROID_RESOURCE || scheme == ContentResolver.SCHEME_CONTENT || scheme == RawResourceDataSource.RAW_RESOURCE_SCHEME || scheme == "res" || host == null
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="play">Play</string>
|
||||
<string name="playback_channel_name">Now Playing</string>
|
||||
<string name="pause">Pause</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user