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,98 @@
|
|
|
1
|
+
package org.dwbn.plugins.playlist.manager
|
|
2
|
+
|
|
3
|
+
import androidx.media3.common.Player
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Playback decisions that keep the Media3 playlist behaving like the former PlaylistCore stack.
|
|
7
|
+
* Pure functions so JVM tests pin the parity rules.
|
|
8
|
+
*/
|
|
9
|
+
object PlaylistPlaybackPolicy {
|
|
10
|
+
/** PlaylistCore skipped a failing item up to this many times in a row before giving up. */
|
|
11
|
+
const val MAX_SEQUENTIAL_ERRORS = 3
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PlaylistCore never repeated a single item: with one item `next` was unavailable even when
|
|
15
|
+
* looping, so playback stopped at its end. Media3 `REPEAT_MODE_ALL` would restart it forever.
|
|
16
|
+
*/
|
|
17
|
+
@JvmStatic
|
|
18
|
+
fun repeatMode(loop: Boolean, itemCount: Int): Int =
|
|
19
|
+
if (loop && itemCount > 1) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
|
|
20
|
+
|
|
21
|
+
@JvmStatic
|
|
22
|
+
fun nextAvailable(index: Int, itemCount: Int, loop: Boolean): Boolean {
|
|
23
|
+
if (itemCount <= 1) {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
val isAtEnd = index + 1 >= itemCount
|
|
27
|
+
return if (isAtEnd) loop else index + 1 in 0 until itemCount
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Window index for [Player.seekTo] after a failed item, matching [PlaylistManager.skipFailedItem]:
|
|
32
|
+
* next item, or wrap to 0 at the end of the list.
|
|
33
|
+
*/
|
|
34
|
+
@JvmStatic
|
|
35
|
+
fun errorSkipWindowIndex(failedIndex: Int, itemCount: Int): Int {
|
|
36
|
+
if (itemCount <= 0) {
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
return if (failedIndex + 1 >= itemCount) 0 else failedIndex + 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@JvmStatic
|
|
43
|
+
fun previousAvailable(index: Int, loop: Boolean, itemCount: Int): Boolean {
|
|
44
|
+
if (itemCount <= 0) {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
return index > 0 || loop
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Window index for [Player.seekTo] when skipping forward, matching [PlaylistManager.skipToNext]:
|
|
52
|
+
* next item, or wrap to 0 at the end of the list when looping.
|
|
53
|
+
*/
|
|
54
|
+
@JvmStatic
|
|
55
|
+
fun nextSkipIndex(fromIndex: Int, itemCount: Int, loop: Boolean): Int =
|
|
56
|
+
if (fromIndex + 1 >= itemCount && loop) 0 else fromIndex + 1
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Window index for [Player.seekTo] when skipping backward, matching
|
|
60
|
+
* [PlaylistManager.skipToPrevious]: previous item, or wrap to the last item when looping.
|
|
61
|
+
*/
|
|
62
|
+
@JvmStatic
|
|
63
|
+
fun previousSkipIndex(fromIndex: Int, itemCount: Int, loop: Boolean): Int =
|
|
64
|
+
if (fromIndex <= 0 && loop) itemCount - 1 else fromIndex - 1
|
|
65
|
+
|
|
66
|
+
/** An item played to its end and the player moved on by itself (not a seek or list change). */
|
|
67
|
+
@JvmStatic
|
|
68
|
+
fun isNaturalItemEnd(transitionReason: Int): Boolean =
|
|
69
|
+
transitionReason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
|
|
70
|
+
transitionReason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT
|
|
71
|
+
|
|
72
|
+
@JvmStatic
|
|
73
|
+
fun shouldSkipAfterError(sequentialErrors: Int, nextAvailable: Boolean): Boolean =
|
|
74
|
+
nextAvailable && sequentialErrors <= MAX_SEQUENTIAL_ERRORS
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* PlaylistCore assumed the user had pressed play for every item after the first, so it kept
|
|
78
|
+
* playing after skipping a broken item unless the failure hit the auto-buffered first item.
|
|
79
|
+
*/
|
|
80
|
+
@JvmStatic
|
|
81
|
+
fun playWhenReadyAfterErrorSkip(playWhenReady: Boolean, failedIndex: Int): Boolean =
|
|
82
|
+
playWhenReady || failedIndex > 0
|
|
83
|
+
|
|
84
|
+
/** Media3 ignores play on an idle or ended player; it must be prepared or rewound first. */
|
|
85
|
+
@JvmStatic
|
|
86
|
+
fun needsPrepareBeforePlay(playbackState: Int): Boolean = playbackState == Player.STATE_IDLE
|
|
87
|
+
|
|
88
|
+
@JvmStatic
|
|
89
|
+
fun needsRewindBeforePlay(playbackState: Int): Boolean = playbackState == Player.STATE_ENDED
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `resetStreamOnPause`: resume a paused live stream at the live edge instead of where it paused.
|
|
93
|
+
* VOD streams (`isStream` HLS lectures) are not live and keep their position.
|
|
94
|
+
*/
|
|
95
|
+
@JvmStatic
|
|
96
|
+
fun shouldJumpToLiveEdge(resetStreamOnPause: Boolean, isStream: Boolean, isLive: Boolean): Boolean =
|
|
97
|
+
resetStreamOnPause && isStream && isLive
|
|
98
|
+
}
|
|
@@ -1,262 +1,158 @@
|
|
|
1
1
|
package org.dwbn.plugins.playlist.playlist;
|
|
2
2
|
|
|
3
|
-
import android.app.Service;
|
|
4
3
|
import android.content.Context;
|
|
5
|
-
import android.util.Log;
|
|
6
|
-
|
|
7
4
|
import androidx.annotation.Nullable;
|
|
8
|
-
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import com.devbrackets.android.playlistcore.components.audiofocus.AudioFocusProvider;
|
|
12
|
-
import com.devbrackets.android.playlistcore.components.audiofocus.DefaultAudioFocusProvider;
|
|
13
|
-
import com.devbrackets.android.playlistcore.components.image.ImageProvider;
|
|
14
|
-
import com.devbrackets.android.playlistcore.components.mediacontrols.DefaultMediaControlsProvider;
|
|
15
|
-
import com.devbrackets.android.playlistcore.components.mediacontrols.MediaControlsProvider;
|
|
16
|
-
import com.devbrackets.android.playlistcore.components.mediasession.DefaultMediaSessionProvider;
|
|
17
|
-
import com.devbrackets.android.playlistcore.components.mediasession.MediaSessionProvider;
|
|
18
|
-
import com.devbrackets.android.playlistcore.components.playlisthandler.DefaultPlaylistHandler;
|
|
19
|
-
import com.devbrackets.android.playlistcore.data.PlaybackState;
|
|
20
|
-
import com.devbrackets.android.playlistcore.manager.BasePlaylistManager;
|
|
21
|
-
|
|
22
|
-
import org.dwbn.plugins.playlist.RmxAudioPlayer;
|
|
5
|
+
import androidx.annotation.OptIn;
|
|
6
|
+
import androidx.media3.common.Player;
|
|
7
|
+
import androidx.media3.common.util.UnstableApi;
|
|
23
8
|
import org.dwbn.plugins.playlist.data.AudioTrack;
|
|
24
9
|
import org.dwbn.plugins.playlist.manager.PlaylistManager;
|
|
25
|
-
import org.dwbn.plugins.playlist.
|
|
26
|
-
import org.dwbn.plugins.playlist.
|
|
27
|
-
import org.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
10
|
+
import org.dwbn.plugins.playlist.manager.PlaylistPlaybackPolicy;
|
|
11
|
+
import org.dwbn.plugins.playlist.handoff.VideoPlayerBridge;
|
|
12
|
+
import org.dwbn.plugins.playlist.service.MediaNotificationPolicy;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ExoPlayer playback facade for {@link org.dwbn.plugins.playlist.PlaylistPlugin}. Story 55.4
|
|
16
|
+
* replaces PlaylistCore's {@code DefaultPlaylistHandler}; notification/focus polish is Story 55.5,
|
|
17
|
+
* full video handoff behaviour is Story 55.6.
|
|
18
|
+
*/
|
|
19
|
+
@OptIn(markerClass = UnstableApi.class)
|
|
20
|
+
public class AudioPlaylistHandler {
|
|
21
|
+
|
|
22
|
+
private final Context context;
|
|
23
|
+
private final PlaylistManager playlistManager;
|
|
24
|
+
|
|
25
|
+
public AudioPlaylistHandler(Context context, PlaylistManager playlistManager) {
|
|
26
|
+
this.context = context.getApplicationContext();
|
|
27
|
+
this.playlistManager = playlistManager;
|
|
28
|
+
}
|
|
32
29
|
|
|
33
|
-
|
|
30
|
+
public boolean isPlaying() {
|
|
31
|
+
return playlistManager.isPlaying();
|
|
32
|
+
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
M playlistManager,
|
|
39
|
-
ImageProvider<I> imageProvider,
|
|
40
|
-
com.devbrackets.android.playlistcore.components.notification.PlaylistNotificationProvider notificationProvider,
|
|
41
|
-
MediaSessionProvider mediaSessionProvider,
|
|
42
|
-
MediaControlsProvider mediaControlsProvider,
|
|
43
|
-
AudioFocusProvider<I> audioFocusProvider,
|
|
44
|
-
@Nullable Listener<I> listener
|
|
45
|
-
) {
|
|
46
|
-
super(context, serviceClass, playlistManager, imageProvider, notificationProvider,
|
|
47
|
-
mediaSessionProvider, mediaControlsProvider, audioFocusProvider, listener);
|
|
48
|
-
// Lmao this entire class exists for the sake of this one line
|
|
49
|
-
// The default value is 30fps (e.g 33ms), which would overwhelm the Cordova webview with messages
|
|
50
|
-
// Ideally we could make this configurable.
|
|
51
|
-
getMediaProgressPoll().setProgressPollDelay(1000);
|
|
34
|
+
@Nullable
|
|
35
|
+
public Player getCurrentMediaPlayer() {
|
|
36
|
+
return playlistManager.getPlayer();
|
|
52
37
|
}
|
|
53
38
|
|
|
54
|
-
public void
|
|
55
|
-
if (
|
|
39
|
+
public void play() {
|
|
40
|
+
if (MediaNotificationPolicy.shouldIgnoreSessionPlay(
|
|
41
|
+
playlistManager.isVideoHandoffPrewarmActive(),
|
|
42
|
+
VideoPlayerBridge.hasActivePlayer()
|
|
43
|
+
)) {
|
|
44
|
+
playlistManager.ensureForeground();
|
|
56
45
|
return;
|
|
57
46
|
}
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
playlistManager.ensureServiceStarted();
|
|
48
|
+
playlistManager.ensureForeground();
|
|
49
|
+
if (MediaNotificationPolicy.shouldRequestLegacyAudioFocus()) {
|
|
50
|
+
// Media3 handleAudioFocus owns pause/resume; do not call AudioManager.requestAudioFocus.
|
|
51
|
+
}
|
|
52
|
+
Player player = playlistManager.getPlayer();
|
|
53
|
+
AudioTrack track = playlistManager.getCurrentItem();
|
|
54
|
+
jumpToLiveEdgeIfReset(player, playlistManager.getResetStreamOnPause(), track != null && track.isStream());
|
|
55
|
+
startAudible(player);
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
/** Visible for JVM tests — `resetStreamOnPause` resumes live streams at the live edge. */
|
|
59
|
+
static void jumpToLiveEdgeIfReset(@Nullable Player player, boolean resetStreamOnPause, boolean isStream) {
|
|
60
|
+
if (player != null
|
|
61
|
+
&& PlaylistPlaybackPolicy.shouldJumpToLiveEdge(resetStreamOnPause, isStream, player.isCurrentMediaItemLive())) {
|
|
62
|
+
player.seekToDefaultPosition();
|
|
65
63
|
}
|
|
66
|
-
getPlaylistManager().previous();
|
|
67
|
-
startItemPlayback(0, !this.isPlaying());
|
|
68
64
|
}
|
|
69
65
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* pauseForVideoHandoff while the video ExoPlayer still held the stream).
|
|
74
|
-
* During Epic 45 prewarm (native video active), never request focus or start audible playback.
|
|
75
|
-
*/
|
|
76
|
-
@Override
|
|
77
|
-
public void play() {
|
|
78
|
-
if (isVideoHandoffPrewarmActive()) {
|
|
79
|
-
setupForeground();
|
|
66
|
+
/** Visible for JVM tests — PlaylistCore's play restarted an idle or finished player. */
|
|
67
|
+
static void startAudible(@Nullable Player player) {
|
|
68
|
+
if (player == null) {
|
|
80
69
|
return;
|
|
81
70
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
mediaPlayer.play();
|
|
71
|
+
int state = player.getPlaybackState();
|
|
72
|
+
if (PlaylistPlaybackPolicy.needsRewindBeforePlay(state)) {
|
|
73
|
+
player.seekToDefaultPosition(player.getCurrentMediaItemIndex());
|
|
74
|
+
}
|
|
75
|
+
if (PlaylistPlaybackPolicy.needsPrepareBeforePlay(state)) {
|
|
76
|
+
player.prepare();
|
|
89
77
|
}
|
|
90
|
-
|
|
91
|
-
setPlaybackState(PlaybackState.PLAYING);
|
|
92
|
-
setupForeground();
|
|
78
|
+
player.setPlayWhenReady(true);
|
|
93
79
|
}
|
|
94
80
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
* {@code playingBeforeSeek = isPlaying()}. After video handoff the player is paused, so
|
|
100
|
-
* seek-then-play races with {@code onSeekComplete}, which calls {@code pause()} when
|
|
101
|
-
* {@code playingBeforeSeek} is false — UI briefly shows playing while native audio stays silent.
|
|
102
|
-
*/
|
|
103
|
-
public void resumePlaybackAfterVideoHandoff(long positionMs) {
|
|
104
|
-
((PlaylistManager) getPlaylistManager()).setVideoHandoffForegroundRetain(false);
|
|
105
|
-
setStartPaused(false);
|
|
106
|
-
getAudioFocusProvider().requestFocus();
|
|
107
|
-
play();
|
|
108
|
-
if (positionMs > 0) {
|
|
109
|
-
seek(positionMs);
|
|
81
|
+
public void pause(boolean isTemporary) {
|
|
82
|
+
Player player = playlistManager.getPlayer();
|
|
83
|
+
if (player != null) {
|
|
84
|
+
player.setPlayWhenReady(false);
|
|
110
85
|
}
|
|
111
86
|
}
|
|
112
87
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (isPlaying()) {
|
|
118
|
-
com.devbrackets.android.playlistcore.api.MediaPlayerApi<I> mediaPlayer = getCurrentMediaPlayer();
|
|
119
|
-
if (mediaPlayer != null) {
|
|
120
|
-
mediaPlayer.pause();
|
|
121
|
-
}
|
|
88
|
+
public void seek(long positionMs) {
|
|
89
|
+
Player player = playlistManager.getPlayer();
|
|
90
|
+
if (player != null) {
|
|
91
|
+
player.seekTo(positionMs);
|
|
122
92
|
}
|
|
123
|
-
getMediaProgressPoll().stop();
|
|
124
|
-
setPlaybackState(com.devbrackets.android.playlistcore.data.PlaybackState.PAUSED);
|
|
125
|
-
getAudioFocusProvider().abandonFocus();
|
|
126
93
|
}
|
|
127
94
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
getMediaProgressPoll().stop();
|
|
138
|
-
setPlaybackState(com.devbrackets.android.playlistcore.data.PlaybackState.PAUSED);
|
|
139
|
-
if (!isTemporary) {
|
|
140
|
-
getAudioFocusProvider().abandonFocus();
|
|
141
|
-
}
|
|
95
|
+
public void startItemPlayback(long positionMs, boolean startPaused) {
|
|
96
|
+
playlistManager.ensureServiceStarted();
|
|
97
|
+
playlistManager.beginPlaybackAt(positionMs, startPaused);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public void next() {
|
|
101
|
+
if (!playlistManager.isNextAvailable()) {
|
|
142
102
|
return;
|
|
143
103
|
}
|
|
144
|
-
|
|
104
|
+
playlistManager.skipToNext();
|
|
145
105
|
}
|
|
146
106
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (((PlaylistManager) getPlaylistManager()).getVideoHandoffForegroundRetain()) {
|
|
107
|
+
public void previous() {
|
|
108
|
+
if (!playlistManager.isPreviousAvailable()) {
|
|
150
109
|
return;
|
|
151
110
|
}
|
|
152
|
-
|
|
111
|
+
playlistManager.skipToPrevious();
|
|
153
112
|
}
|
|
154
113
|
|
|
155
114
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
115
|
+
* Resume at {@code positionMs} after native video ends. Media3 {@code handleAudioFocus} owns
|
|
116
|
+
* focus. Clears retain before audible play, then play-then-seek (seek-while-paused stays silent).
|
|
158
117
|
*/
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
super.setupForeground();
|
|
118
|
+
public void resumePlaybackAfterVideoHandoff(long positionMs) {
|
|
119
|
+
playlistManager.setVideoHandoffForegroundRetain(false);
|
|
120
|
+
playlistManager.ensureServiceStarted();
|
|
121
|
+
playlistManager.ensureForeground();
|
|
122
|
+
applyAudibleResume(playlistManager.getPlayer(), positionMs);
|
|
166
123
|
}
|
|
167
124
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
// Prewarm: prepare at seek position but stay silent — native video owns audio focus.
|
|
172
|
-
super.onPrepared(mediaPlayer);
|
|
173
|
-
com.devbrackets.android.playlistcore.api.MediaPlayerApi<I> currentPlayer = getCurrentMediaPlayer();
|
|
174
|
-
if (currentPlayer != null && currentPlayer.isPlaying()) {
|
|
175
|
-
currentPlayer.pause();
|
|
176
|
-
}
|
|
177
|
-
getMediaProgressPoll().stop();
|
|
178
|
-
setPlaybackState(com.devbrackets.android.playlistcore.data.PlaybackState.PAUSED);
|
|
179
|
-
getAudioFocusProvider().abandonFocus();
|
|
125
|
+
/** Visible for JVM tests — play then seek so audio is not left paused after video. */
|
|
126
|
+
static void applyAudibleResume(@Nullable Player player, long positionMs) {
|
|
127
|
+
if (player == null) {
|
|
180
128
|
return;
|
|
181
129
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
int currentIndex = getPlaylistManager().getCurrentPosition();
|
|
193
|
-
int currentErrorCount = getSequentialErrors();
|
|
194
|
-
|
|
195
|
-
super.onError(mediaPlayer);
|
|
196
|
-
// Do not set startPaused to false if we are at the first item.
|
|
197
|
-
// For all other items, the user MUST have triggered playback;
|
|
198
|
-
// for item 0, they will never have done so at this point (since the tracks
|
|
199
|
-
// are auto-buffered when a list is loaded).
|
|
200
|
-
// This is a bit of a guess. What happens of tracks 2,3,4 are also broken?
|
|
201
|
-
// User has no network? The only way to be *certain* is to capture all user interaction
|
|
202
|
-
// input points and create a global flag "somewhere" that says the user has tried to play.
|
|
203
|
-
// Maintaining that would be a nightmare.
|
|
204
|
-
if (currentIndex > 0 && currentErrorCount <= 3) {
|
|
205
|
-
Log.e(TAG, "ListHandler error: setting startPaused to false");
|
|
206
|
-
setStartPaused(false);
|
|
130
|
+
if (MediaNotificationPolicy.shouldPlayThenSeekOnResume()) {
|
|
131
|
+
player.setPlayWhenReady(true);
|
|
132
|
+
if (positionMs > 0) {
|
|
133
|
+
player.seekTo(positionMs);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
if (positionMs > 0) {
|
|
137
|
+
player.seekTo(positionMs);
|
|
138
|
+
}
|
|
139
|
+
player.setPlayWhenReady(true);
|
|
207
140
|
}
|
|
208
|
-
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
@Override
|
|
213
|
-
public void onSeekComplete(MediaPlayerApi<I> mediaPlayer) {
|
|
214
|
-
Log.i(TAG, "onSeekComplete! " + mediaPlayer.getCurrentPosition());
|
|
215
|
-
getCurrentMediaProgress().update(mediaPlayer.getCurrentPosition(), mediaPlayer.getBufferedPercent(), mediaPlayer.getDuration());
|
|
216
|
-
super.onSeekComplete(mediaPlayer);
|
|
217
141
|
}
|
|
218
142
|
|
|
219
|
-
|
|
220
|
-
public void
|
|
221
|
-
(
|
|
222
|
-
Log.i("AudioPlaylistHandler", "onCompletion");
|
|
223
|
-
// This is called when a single item completes playback.
|
|
224
|
-
// For now, the superclass does the right thing, but we may need to override.
|
|
225
|
-
super.onCompletion(mediaPlayer);
|
|
143
|
+
/** Pause for video without tearing down the foreground service (Epic 45 / Story 55.6). */
|
|
144
|
+
public void pauseForVideoHandoff() {
|
|
145
|
+
pauseForVideoHandoff(playlistManager.getPlayer());
|
|
226
146
|
}
|
|
227
147
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
Class<? extends Service> serviceClass;
|
|
233
|
-
M playlistManager;
|
|
234
|
-
ImageProvider<I> imageProvider;
|
|
235
|
-
|
|
236
|
-
com.devbrackets.android.playlistcore.components.notification.PlaylistNotificationProvider notificationProvider = null;
|
|
237
|
-
MediaSessionProvider mediaSessionProvider = null;
|
|
238
|
-
MediaControlsProvider mediaControlsProvider = null;
|
|
239
|
-
AudioFocusProvider<I> audioFocusProvider = null;
|
|
240
|
-
Listener<I> listener;
|
|
241
|
-
public Builder(Context context, Class<? extends Service> serviceClass,
|
|
242
|
-
M playlistManager, ImageProvider<I> imageProvider, Listener<I> listener) {
|
|
243
|
-
this.context = context;
|
|
244
|
-
this.serviceClass = serviceClass;
|
|
245
|
-
this.playlistManager = playlistManager;
|
|
246
|
-
this.imageProvider = imageProvider;
|
|
247
|
-
this.listener = listener;
|
|
148
|
+
/** Visible for JVM tests — pauses even when {@code isPlaying} is false. */
|
|
149
|
+
static void pauseForVideoHandoff(@Nullable Player player) {
|
|
150
|
+
if (player != null && MediaNotificationPolicy.shouldPauseForVideoHandoff(player.isPlaying())) {
|
|
151
|
+
player.setPlayWhenReady(false);
|
|
248
152
|
}
|
|
153
|
+
}
|
|
249
154
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
serviceClass,
|
|
253
|
-
playlistManager,
|
|
254
|
-
imageProvider,
|
|
255
|
-
notificationProvider != null ? notificationProvider : new PlaylistNotificationProvider(context),
|
|
256
|
-
mediaSessionProvider != null ? mediaSessionProvider : new DefaultMediaSessionProvider(context, serviceClass),
|
|
257
|
-
mediaControlsProvider != null ? mediaControlsProvider : new DefaultMediaControlsProvider(context),
|
|
258
|
-
audioFocusProvider != null ? audioFocusProvider : new DefaultAudioFocusProvider<I>(context),
|
|
259
|
-
listener);
|
|
260
|
-
}
|
|
155
|
+
public void updateMediaControls() {
|
|
156
|
+
playlistManager.updateForegroundNotification();
|
|
261
157
|
}
|
|
262
158
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package org.dwbn.plugins.playlist.service
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.graphics.drawable.Drawable
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
10
|
+
import androidx.annotation.OptIn
|
|
11
|
+
import androidx.media3.common.MediaMetadata
|
|
12
|
+
import androidx.media3.common.util.BitmapLoader
|
|
13
|
+
import androidx.media3.common.util.UnstableApi
|
|
14
|
+
import com.bumptech.glide.Glide
|
|
15
|
+
import com.bumptech.glide.request.target.CustomTarget
|
|
16
|
+
import com.bumptech.glide.request.transition.Transition
|
|
17
|
+
import com.google.common.util.concurrent.ListenableFuture
|
|
18
|
+
import com.google.common.util.concurrent.SettableFuture
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Glide-backed [BitmapLoader] for Media3 notification / lock-screen artwork.
|
|
22
|
+
* Load failures complete the future exceptionally so Media3 keeps the small icon only.
|
|
23
|
+
*/
|
|
24
|
+
@OptIn(UnstableApi::class)
|
|
25
|
+
class GlideBitmapLoader(context: Context) : BitmapLoader {
|
|
26
|
+
private val appContext = context.applicationContext
|
|
27
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
28
|
+
|
|
29
|
+
override fun supportsMimeType(mimeType: String): Boolean {
|
|
30
|
+
return isSupportedImageMimeType(mimeType)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
|
|
34
|
+
val future = SettableFuture.create<Bitmap>()
|
|
35
|
+
try {
|
|
36
|
+
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
|
|
37
|
+
if (bitmap == null) {
|
|
38
|
+
future.setException(IllegalStateException("Failed to decode artwork bytes"))
|
|
39
|
+
} else {
|
|
40
|
+
future.set(bitmap)
|
|
41
|
+
}
|
|
42
|
+
} catch (t: Throwable) {
|
|
43
|
+
if (!future.isDone) {
|
|
44
|
+
future.setException(t)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return future
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
|
|
51
|
+
return loadWithGlide(uri)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap>? {
|
|
55
|
+
if (MediaNotificationPolicy.hasNoLargeArtwork(metadata.artworkData, metadata.artworkUri?.toString())) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
val data = metadata.artworkData
|
|
59
|
+
if (data != null && data.isNotEmpty()) {
|
|
60
|
+
return decodeBitmap(data)
|
|
61
|
+
}
|
|
62
|
+
val uri = metadata.artworkUri
|
|
63
|
+
if (uri != null && MediaNotificationPolicy.shouldLoadRemoteArtwork(uri.toString())) {
|
|
64
|
+
return loadBitmap(uri)
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun loadWithGlide(model: Any): ListenableFuture<Bitmap> {
|
|
70
|
+
val future = SettableFuture.create<Bitmap>()
|
|
71
|
+
val load = Runnable {
|
|
72
|
+
try {
|
|
73
|
+
Glide.with(appContext)
|
|
74
|
+
.asBitmap()
|
|
75
|
+
.load(model)
|
|
76
|
+
.into(object : CustomTarget<Bitmap>() {
|
|
77
|
+
override fun onResourceReady(
|
|
78
|
+
resource: Bitmap,
|
|
79
|
+
transition: Transition<in Bitmap>?
|
|
80
|
+
) {
|
|
81
|
+
future.set(resource)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override fun onLoadFailed(errorDrawable: Drawable?) {
|
|
85
|
+
if (!future.isDone) {
|
|
86
|
+
future.setException(IllegalStateException("Artwork load failed"))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override fun onLoadCleared(placeholder: Drawable?) {
|
|
91
|
+
if (!future.isDone) {
|
|
92
|
+
future.setException(IllegalStateException("Artwork load cleared"))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
} catch (t: Throwable) {
|
|
97
|
+
if (!future.isDone) {
|
|
98
|
+
future.setException(t)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
103
|
+
load.run()
|
|
104
|
+
} else if (!mainHandler.post(load)) {
|
|
105
|
+
future.setException(IllegalStateException("Artwork load post failed"))
|
|
106
|
+
}
|
|
107
|
+
return future
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
companion object {
|
|
111
|
+
@JvmStatic
|
|
112
|
+
fun isSupportedImageMimeType(mimeType: String?): Boolean {
|
|
113
|
+
return !mimeType.isNullOrEmpty() && mimeType.startsWith("image/", ignoreCase = true)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@JvmStatic
|
|
117
|
+
fun shouldLoadRemoteArtwork(artworkUri: String?): Boolean {
|
|
118
|
+
return MediaNotificationPolicy.shouldLoadRemoteArtwork(artworkUri)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|