capacitor-plugin-playlist 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -127
- package/android/build.gradle +31 -18
- package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/android/src/main/java/org/dwbn/plugins/playlist/AudioMediaItemFactory.java +96 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistPlugin.kt +3 -3
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioPlayer.java +145 -188
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxPlaybackErrorMapper.kt +26 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/data/AudioTrack.kt +28 -28
- package/android/src/main/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridge.kt +102 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaybackProgress.kt +8 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistManager.kt +418 -183
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicy.kt +98 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/RmxPlaybackState.kt +11 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandler.java +107 -211
- package/android/src/main/java/org/dwbn/plugins/playlist/service/GlideBitmapLoader.kt +121 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayer.kt +185 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicy.kt +253 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaService.kt +260 -68
- package/android/src/main/java/org/dwbn/plugins/playlist/service/PlaylistMediaSessionCallback.kt +39 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryMimeTest.kt +98 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryTest.java +71 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/data/AudioTrackTest.kt +53 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridgeTest.kt +84 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicyTest.kt +128 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandlerTest.kt +145 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/GlideBitmapLoaderTest.kt +76 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayerTest.kt +282 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicyTest.kt +285 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationCommandsTest.kt +54 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationPolicyTest.kt +47 -0
- package/dist/docs.json +12 -846
- package/dist/esm/RmxAudioPlayer.d.ts +2 -2
- package/dist/esm/RmxAudioPlayer.js.map +1 -1
- package/dist/esm/definitions.d.ts +7 -7
- package/dist/esm/interfaces.d.ts +7 -1
- package/dist/esm/utils.d.ts +1 -1
- package/dist/esm/utils.js +2 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/web.d.ts +3 -3
- package/dist/esm/web.js +5 -5
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +7 -7
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +7 -7
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/PlaylistPlugin/AVBidirectionalQueuePlayer.swift +45 -6
- package/ios/Sources/PlaylistPlugin/AudioTrack.swift +15 -2
- package/ios/Tests/PlaylistPluginTests/PositionResumeTests.swift +82 -0
- package/package.json +15 -19
- package/android/src/main/java/org/dwbn/plugins/playlist/notification/PlaylistNotificationProvider.kt +0 -26
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioApi.kt +0 -114
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/BaseMediaApi.kt +0 -36
- package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaImageProvider.kt +0 -83
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
package org.dwbn.plugins.playlist.service
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import androidx.annotation.OptIn
|
|
6
|
+
import androidx.media3.common.ForwardingPlayer
|
|
7
|
+
import androidx.media3.common.Player
|
|
8
|
+
import androidx.media3.common.util.UnstableApi
|
|
9
|
+
import org.dwbn.plugins.playlist.handoff.VideoPlayerBridge
|
|
10
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Routes the playlist MediaSession to the video ExoPlayer while handoff retain is active and video
|
|
14
|
+
* is attached; otherwise blocks play during retain-only prewarm (Story 55.6).
|
|
15
|
+
*/
|
|
16
|
+
@OptIn(UnstableApi::class)
|
|
17
|
+
internal class HandoffForwardingPlayer(
|
|
18
|
+
player: Player,
|
|
19
|
+
private val retain: () -> Boolean,
|
|
20
|
+
private val onSkipToNext: (() -> Unit)? = null,
|
|
21
|
+
private val onSkipToPrevious: (() -> Unit)? = null,
|
|
22
|
+
private val previousAvailable: (() -> Boolean)? = null,
|
|
23
|
+
private val nextAvailable: (() -> Boolean)? = null
|
|
24
|
+
) : ForwardingPlayer(player) {
|
|
25
|
+
|
|
26
|
+
private val sessionListeners = CopyOnWriteArrayList<Player.Listener>()
|
|
27
|
+
private val playbackRelay = { dispatchVideoPlaybackToSession() }
|
|
28
|
+
|
|
29
|
+
init {
|
|
30
|
+
VideoPlayerBridge.addPlaybackChangedListener(playbackRelay)
|
|
31
|
+
if (VideoPlayerBridge.hasActivePlayer()) {
|
|
32
|
+
playbackRelay()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override fun addListener(listener: Player.Listener) {
|
|
37
|
+
super.addListener(listener)
|
|
38
|
+
sessionListeners.addIfAbsent(listener)
|
|
39
|
+
if (videoPlayer() != null) {
|
|
40
|
+
playbackRelay()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override fun removeListener(listener: Player.Listener) {
|
|
45
|
+
super.removeListener(listener)
|
|
46
|
+
sessionListeners.remove(listener)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun videoPlayer(): Player? {
|
|
50
|
+
if (!retain() || !VideoPlayerBridge.hasActivePlayer()) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
return VideoPlayerBridge.activePlayer()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override fun play() {
|
|
57
|
+
val video = videoPlayer()
|
|
58
|
+
if (video != null) {
|
|
59
|
+
video.play()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (MediaNotificationPolicy.shouldIgnoreSessionPlay(retain(), VideoPlayerBridge.hasActivePlayer())) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
super.play()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
override fun pause() {
|
|
69
|
+
videoPlayer()?.pause() ?: super.pause()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun setPlayWhenReady(playWhenReady: Boolean) {
|
|
73
|
+
val video = videoPlayer()
|
|
74
|
+
if (video != null) {
|
|
75
|
+
video.playWhenReady = playWhenReady
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (playWhenReady && MediaNotificationPolicy.shouldIgnoreSessionPlay(retain(), VideoPlayerBridge.hasActivePlayer())) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
super.setPlayWhenReady(playWhenReady)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override fun getPlayWhenReady(): Boolean = videoPlayer()?.playWhenReady ?: super.getPlayWhenReady()
|
|
85
|
+
|
|
86
|
+
override fun isPlaying(): Boolean {
|
|
87
|
+
val video = videoPlayer()
|
|
88
|
+
if (video != null) {
|
|
89
|
+
return video.playWhenReady && video.playbackState == Player.STATE_READY
|
|
90
|
+
}
|
|
91
|
+
return super.isPlaying()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override fun getPlaybackState(): Int = videoPlayer()?.playbackState ?: super.getPlaybackState()
|
|
95
|
+
|
|
96
|
+
override fun getCurrentPosition(): Long = videoPlayer()?.currentPosition ?: super.getCurrentPosition()
|
|
97
|
+
|
|
98
|
+
override fun getContentPosition(): Long = videoPlayer()?.contentPosition ?: super.getContentPosition()
|
|
99
|
+
|
|
100
|
+
override fun getDuration(): Long = videoPlayer()?.duration ?: super.getDuration()
|
|
101
|
+
|
|
102
|
+
override fun getBufferedPosition(): Long = videoPlayer()?.bufferedPosition ?: super.getBufferedPosition()
|
|
103
|
+
|
|
104
|
+
override fun seekTo(positionMs: Long) {
|
|
105
|
+
videoPlayer()?.seekTo(positionMs) ?: super.seekTo(positionMs)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override fun seekTo(windowIndex: Int, positionMs: Long) {
|
|
109
|
+
videoPlayer()?.seekTo(windowIndex, positionMs) ?: super.seekTo(windowIndex, positionMs)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override fun seekToNextMediaItem() {
|
|
113
|
+
if (videoPlayer() != null) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
onSkipToNext?.invoke() ?: super.seekToNextMediaItem()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override fun seekToPreviousMediaItem() {
|
|
120
|
+
if (videoPlayer() != null) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
onSkipToPrevious?.invoke() ?: super.seekToPreviousMediaItem()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
override fun getAvailableCommands(): Player.Commands {
|
|
127
|
+
val previous = previousAvailable
|
|
128
|
+
val next = nextAvailable
|
|
129
|
+
if (previous == null && next == null) {
|
|
130
|
+
return super.getAvailableCommands()
|
|
131
|
+
}
|
|
132
|
+
val retainActive = retain()
|
|
133
|
+
val videoAttached = VideoPlayerBridge.hasActivePlayer()
|
|
134
|
+
return MediaNotificationPolicy.playerCommandsForMediaNotification(
|
|
135
|
+
super.getAvailableCommands(),
|
|
136
|
+
MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(
|
|
137
|
+
previous?.invoke() ?: false,
|
|
138
|
+
retainActive,
|
|
139
|
+
videoAttached
|
|
140
|
+
),
|
|
141
|
+
MediaNotificationPolicy.mediaNotificationSkipNextEnabled(
|
|
142
|
+
next?.invoke() ?: false,
|
|
143
|
+
retainActive,
|
|
144
|
+
videoAttached
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private fun dispatchVideoPlaybackToSession() {
|
|
150
|
+
val looper = applicationLooperOrNull()
|
|
151
|
+
if (looper != null && Looper.myLooper() != looper) {
|
|
152
|
+
Handler(looper).post { dispatchVideoPlaybackToSessionOnLooper() }
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
dispatchVideoPlaybackToSessionOnLooper()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private fun dispatchVideoPlaybackToSessionOnLooper() {
|
|
159
|
+
if (videoPlayer() == null) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
val ready = playWhenReady
|
|
163
|
+
val state = playbackState
|
|
164
|
+
val playing = isPlaying
|
|
165
|
+
val commands = try {
|
|
166
|
+
availableCommands
|
|
167
|
+
} catch (_: Throwable) {
|
|
168
|
+
null
|
|
169
|
+
}
|
|
170
|
+
for (listener in sessionListeners) {
|
|
171
|
+
listener.onPlayWhenReadyChanged(ready, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
|
172
|
+
listener.onPlaybackStateChanged(state)
|
|
173
|
+
listener.onIsPlayingChanged(playing)
|
|
174
|
+
if (commands != null) {
|
|
175
|
+
listener.onAvailableCommandsChanged(commands)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun applicationLooperOrNull(): Looper? = try {
|
|
181
|
+
applicationLooper
|
|
182
|
+
} catch (_: Throwable) {
|
|
183
|
+
null
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
package org.dwbn.plugins.playlist.service
|
|
2
|
+
|
|
3
|
+
import android.content.Intent
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import androidx.annotation.OptIn
|
|
6
|
+
import androidx.media3.common.C
|
|
7
|
+
import androidx.media3.common.Player
|
|
8
|
+
import androidx.media3.common.util.UnstableApi
|
|
9
|
+
import androidx.media3.session.DefaultMediaNotificationProvider
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pure notification / focus / video-handoff decisions for Stories 55.5–55.6.
|
|
13
|
+
* [MediaService], [org.dwbn.plugins.playlist.playlist.AudioPlaylistHandler] and
|
|
14
|
+
* [org.dwbn.plugins.playlist.RmxAudioPlayer] call these so JVM tests can cover the I/O matrix
|
|
15
|
+
* without a device.
|
|
16
|
+
*/
|
|
17
|
+
@OptIn(UnstableApi::class)
|
|
18
|
+
object MediaNotificationPolicy {
|
|
19
|
+
const val HANDLE_AUDIO_FOCUS = true
|
|
20
|
+
|
|
21
|
+
/** FGS start blocked by notification permission or related security policy. */
|
|
22
|
+
@JvmStatic
|
|
23
|
+
fun isForegroundStartSecurityBlocked(error: Throwable): Boolean = error is SecurityException
|
|
24
|
+
|
|
25
|
+
@JvmStatic
|
|
26
|
+
fun isForegroundStartNotAllowedFromBackground(error: Throwable): Boolean =
|
|
27
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
|
28
|
+
error is android.app.ForegroundServiceStartNotAllowedException
|
|
29
|
+
|
|
30
|
+
/** Distinct from the video plugin session (`org.dwbn.video`); Media3 forbids two empty IDs. */
|
|
31
|
+
const val MEDIA_SESSION_ID = "org.dwbn.playlist"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* [MediaService.promoteToForeground] must not call [android.app.Service.startForeground]
|
|
35
|
+
* (Android 12+ forbids restarting FGS from the background after video).
|
|
36
|
+
* FGS starts at the beginning of [MediaService.onCreate] so the startForegroundService
|
|
37
|
+
* timeout cannot fire while ExoPlayer / MediaSession are built. Media3 then replaces that
|
|
38
|
+
* notification; [MediaService.onStartCommand] must not post the title-only placeholder again.
|
|
39
|
+
*/
|
|
40
|
+
const val ALLOW_START_FOREGROUND_ON_PROMOTE = false
|
|
41
|
+
|
|
42
|
+
/** Title-only FGS placeholder is only allowed before the session exists (onCreate start). */
|
|
43
|
+
const val ALLOW_TITLE_ONLY_NOTIFICATION_AFTER_SESSION = false
|
|
44
|
+
|
|
45
|
+
/** Show Media3's session notification before playback is ready so FGS starts within the timeout. */
|
|
46
|
+
const val SHOW_NOTIFICATION_WHEN_IDLE =
|
|
47
|
+
androidx.media3.session.MediaSessionService.SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS
|
|
48
|
+
|
|
49
|
+
val mediaNotificationProviderClass: Class<out DefaultMediaNotificationProvider>
|
|
50
|
+
get() = DefaultMediaNotificationProvider::class.java
|
|
51
|
+
|
|
52
|
+
@JvmStatic
|
|
53
|
+
fun shouldStartForegroundOnPromote(): Boolean = ALLOW_START_FOREGROUND_ON_PROMOTE
|
|
54
|
+
|
|
55
|
+
/** A second [android.content.Context.startForegroundService] while onCreate is still running trips the FGS timeout. */
|
|
56
|
+
@JvmStatic
|
|
57
|
+
fun shouldStartForegroundService(serviceAlreadyCreated: Boolean): Boolean = !serviceAlreadyCreated
|
|
58
|
+
|
|
59
|
+
@JvmStatic
|
|
60
|
+
fun shouldRequestLegacyAudioFocus(): Boolean = false
|
|
61
|
+
|
|
62
|
+
@JvmStatic
|
|
63
|
+
fun shouldLoadRemoteArtwork(artworkUri: String?): Boolean = !artworkUri.isNullOrEmpty()
|
|
64
|
+
|
|
65
|
+
@JvmStatic
|
|
66
|
+
fun hasNoLargeArtwork(artworkData: ByteArray?, artworkUri: String?): Boolean =
|
|
67
|
+
(artworkData == null || artworkData.isEmpty()) && !shouldLoadRemoteArtwork(artworkUri)
|
|
68
|
+
|
|
69
|
+
@JvmStatic
|
|
70
|
+
fun shouldSkipEndForeground(videoHandoffForegroundRetain: Boolean): Boolean =
|
|
71
|
+
videoHandoffForegroundRetain
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pause even when buffering / [androidx.media3.common.Player.isPlaying] is false so Media3
|
|
75
|
+
* [handleAudioFocus] abandons. Teaching-sequence video may call prepare while not playing.
|
|
76
|
+
*/
|
|
77
|
+
@JvmStatic
|
|
78
|
+
fun shouldPauseForVideoHandoff(isPlaying: Boolean): Boolean = true
|
|
79
|
+
|
|
80
|
+
/** Teaching-sequence path has no prewarm — retain must start on prepare. */
|
|
81
|
+
@JvmStatic
|
|
82
|
+
fun shouldRetainForegroundOnPrepare(): Boolean = true
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Media3 caps [androidx.media3.session.MediaSessionService.setForegroundServiceTimeoutMs]
|
|
86
|
+
* at 10 minutes, so [MediaService.onUpdateNotificationAsync] must force
|
|
87
|
+
* `startInForegroundRequired=true` while retain is set.
|
|
88
|
+
*/
|
|
89
|
+
@JvmStatic
|
|
90
|
+
fun shouldForceNotificationForeground(videoHandoffForegroundRetain: Boolean): Boolean =
|
|
91
|
+
videoHandoffForegroundRetain
|
|
92
|
+
|
|
93
|
+
@JvmStatic
|
|
94
|
+
fun startInForegroundRequired(
|
|
95
|
+
videoHandoffForegroundRetain: Boolean,
|
|
96
|
+
media3Requested: Boolean
|
|
97
|
+
): Boolean = media3Requested || shouldForceNotificationForeground(videoHandoffForegroundRetain)
|
|
98
|
+
|
|
99
|
+
/** MediaSession / notification play must not start audible audio while retain is set and video is absent. */
|
|
100
|
+
@JvmStatic
|
|
101
|
+
fun shouldIgnoreSessionPlay(videoHandoffForegroundRetain: Boolean): Boolean =
|
|
102
|
+
shouldIgnoreSessionPlay(videoHandoffForegroundRetain, videoHandoffPlayerAttached = false)
|
|
103
|
+
|
|
104
|
+
@JvmStatic
|
|
105
|
+
fun shouldIgnoreSessionPlay(
|
|
106
|
+
videoHandoffForegroundRetain: Boolean,
|
|
107
|
+
videoHandoffPlayerAttached: Boolean
|
|
108
|
+
): Boolean = videoHandoffForegroundRetain && !videoHandoffPlayerAttached
|
|
109
|
+
|
|
110
|
+
@JvmStatic
|
|
111
|
+
fun shouldDelegateNotificationToVideo(
|
|
112
|
+
videoHandoffForegroundRetain: Boolean,
|
|
113
|
+
videoHandoffPlayerAttached: Boolean
|
|
114
|
+
): Boolean = videoHandoffForegroundRetain && videoHandoffPlayerAttached
|
|
115
|
+
|
|
116
|
+
@JvmStatic
|
|
117
|
+
fun mediaNotificationSkipPreviousEnabled(
|
|
118
|
+
previousAvailable: Boolean,
|
|
119
|
+
videoHandoffForegroundRetain: Boolean,
|
|
120
|
+
videoHandoffPlayerAttached: Boolean
|
|
121
|
+
): Boolean {
|
|
122
|
+
if (shouldDelegateNotificationToVideo(videoHandoffForegroundRetain, videoHandoffPlayerAttached)) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
return mediaNotificationSkipPreviousEnabled(previousAvailable)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@JvmStatic
|
|
129
|
+
fun mediaNotificationSkipNextEnabled(
|
|
130
|
+
nextAvailable: Boolean,
|
|
131
|
+
videoHandoffForegroundRetain: Boolean,
|
|
132
|
+
videoHandoffPlayerAttached: Boolean
|
|
133
|
+
): Boolean {
|
|
134
|
+
if (shouldDelegateNotificationToVideo(videoHandoffForegroundRetain, videoHandoffPlayerAttached)) {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
return mediaNotificationSkipNextEnabled(nextAvailable)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Android audible resume stays play then seek (not seek then play). */
|
|
141
|
+
@JvmStatic
|
|
142
|
+
fun shouldPlayThenSeekOnResume(): Boolean = true
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `{ resumed: true }` only for in-place audible resume while FGS is already up.
|
|
146
|
+
* Prewarm, paused exit, and missing FGS all return false so JS may `playTrackById`.
|
|
147
|
+
*/
|
|
148
|
+
@JvmStatic
|
|
149
|
+
fun shouldReportInPlaceResumed(
|
|
150
|
+
prewarm: Boolean,
|
|
151
|
+
play: Boolean,
|
|
152
|
+
retain: Boolean,
|
|
153
|
+
serviceInForeground: Boolean
|
|
154
|
+
): Boolean = !prewarm && play && retain && serviceInForeground
|
|
155
|
+
|
|
156
|
+
/** [org.dwbn.plugins.playlist.RmxAudioPlayer.getLastKnownPositionSec] is the handoff snapshot. */
|
|
157
|
+
@JvmStatic
|
|
158
|
+
fun lastKnownPositionSec(handoffPositionSec: Float): Float = handoffPositionSec
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Failed in-place resume must not last-resort [org.dwbn.plugins.playlist.manager.PlaylistManager.beginPlayback]
|
|
162
|
+
* (Android 12+ forbids starting FGS from the background after video). JS may play/seek.
|
|
163
|
+
*/
|
|
164
|
+
@JvmStatic
|
|
165
|
+
fun shouldBeginPlaybackWhenInPlaceUnavailable(): Boolean = false
|
|
166
|
+
|
|
167
|
+
/** Teaching-sequence retain starts on prepare — [org.dwbn.plugins.playlist.RmxAudioPlayer] must call this. */
|
|
168
|
+
@JvmStatic
|
|
169
|
+
fun applyForegroundRetainOnPrepare(setRetain: Runnable) {
|
|
170
|
+
if (shouldRetainForegroundOnPrepare()) {
|
|
171
|
+
setRetain.run()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Miss-path audible resume must not [org.dwbn.plugins.playlist.manager.PlaylistManager.beginPlayback]. */
|
|
176
|
+
@JvmStatic
|
|
177
|
+
fun applyBeginPlaybackWhenInPlaceUnavailable(beginPlayback: Runnable) {
|
|
178
|
+
if (shouldBeginPlaybackWhenInPlaceUnavailable()) {
|
|
179
|
+
beginPlayback.run()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* [org.dwbn.plugins.playlist.manager.PlaylistManager] player listener must call this when
|
|
185
|
+
* [androidx.media3.common.Player.Listener.onPlayWhenReadyChanged] fires.
|
|
186
|
+
*/
|
|
187
|
+
@JvmStatic
|
|
188
|
+
fun applyRetainPlayWhenReadyGuard(
|
|
189
|
+
playWhenReady: Boolean,
|
|
190
|
+
videoHandoffForegroundRetain: Boolean,
|
|
191
|
+
videoHandoffPlayerAttached: Boolean,
|
|
192
|
+
pausePlayer: Runnable
|
|
193
|
+
) {
|
|
194
|
+
if (playWhenReady &&
|
|
195
|
+
shouldIgnoreSessionPlay(videoHandoffForegroundRetain, videoHandoffPlayerAttached)
|
|
196
|
+
) {
|
|
197
|
+
pausePlayer.run()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@JvmStatic
|
|
202
|
+
fun sessionLaunchIntentFlags(): Int =
|
|
203
|
+
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
204
|
+
|
|
205
|
+
@JvmStatic
|
|
206
|
+
fun shouldOverwriteMedia3NotificationOnStartCommand(): Boolean = false
|
|
207
|
+
|
|
208
|
+
@JvmStatic
|
|
209
|
+
fun shouldAllowTitleOnlyNotificationAfterSession(): Boolean =
|
|
210
|
+
ALLOW_TITLE_ONLY_NOTIFICATION_AFTER_SESSION
|
|
211
|
+
|
|
212
|
+
/** CPU + Wi-Fi lock while playing, as PlaylistCore's WifiLock and ExoMedia's partial wake lock did. */
|
|
213
|
+
const val WAKE_MODE = C.WAKE_MODE_NETWORK
|
|
214
|
+
|
|
215
|
+
/** `setOptions({ options: { icon } })` names an app drawable; fall back to the platform play glyph. */
|
|
216
|
+
@JvmStatic
|
|
217
|
+
fun smallIconRes(resolvedIconRes: Int): Int =
|
|
218
|
+
if (resolvedIconRes != 0) resolvedIconRes else android.R.drawable.ic_media_play
|
|
219
|
+
|
|
220
|
+
/** Mirrors [org.dwbn.plugins.playlist.manager.PlaylistManager.isPreviousAvailable] for JVM tests. */
|
|
221
|
+
@JvmStatic
|
|
222
|
+
fun mediaNotificationSkipPreviousEnabled(previousAvailable: Boolean): Boolean = previousAvailable
|
|
223
|
+
|
|
224
|
+
/** Mirrors [org.dwbn.plugins.playlist.manager.PlaylistManager.isNextAvailable] for JVM tests. */
|
|
225
|
+
@JvmStatic
|
|
226
|
+
fun mediaNotificationSkipNextEnabled(nextAvailable: Boolean): Boolean = nextAvailable
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Starts from Media3 default / wrapped player commands so metadata and play-pause stay
|
|
230
|
+
* available. Skip is added or removed from that set — never replace it with a tiny list.
|
|
231
|
+
*/
|
|
232
|
+
@JvmStatic
|
|
233
|
+
fun playerCommandsForMediaNotification(
|
|
234
|
+
base: Player.Commands,
|
|
235
|
+
previousAvailable: Boolean,
|
|
236
|
+
nextAvailable: Boolean
|
|
237
|
+
): Player.Commands {
|
|
238
|
+
val builder = base.buildUpon().add(Player.COMMAND_PLAY_PAUSE)
|
|
239
|
+
if (mediaNotificationSkipPreviousEnabled(previousAvailable)) {
|
|
240
|
+
builder.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
|
241
|
+
} else {
|
|
242
|
+
builder.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
|
243
|
+
builder.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
|
|
244
|
+
}
|
|
245
|
+
if (mediaNotificationSkipNextEnabled(nextAvailable)) {
|
|
246
|
+
builder.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
|
247
|
+
} else {
|
|
248
|
+
builder.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
|
249
|
+
builder.remove(Player.COMMAND_SEEK_TO_NEXT)
|
|
250
|
+
}
|
|
251
|
+
return builder.build()
|
|
252
|
+
}
|
|
253
|
+
}
|