react-native-queue-player 1.1.1 → 1.2.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/android/build.gradle +7 -0
- package/android/consumer-rules.pro +72 -3
- package/android/src/main/cpp/airplay2_jni.cpp +23 -3
- package/android/src/main/cpp/airplay_jni.cpp +12 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +31 -7
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +19 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +6 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +26 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +6 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +162 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +122 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
- package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
- package/ios/Cast/Core/CastSession.swift +11 -0
- package/ios/GaplessEngine.swift +11 -5
- package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
- package/ios/Tests/GaplessEngineLifecycleTests.swift +26 -0
- package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
- package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
- package/ios/TrackPlayer.swift +32 -32
- package/package.json +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
|
@@ -177,6 +177,7 @@ final class ControllableRemoteSession: CastSessionRemotePlayer {
|
|
|
177
177
|
var isPushSink: Bool { pushSink }
|
|
178
178
|
|
|
179
179
|
var lastState: RemotePlaybackStateKind = .idle
|
|
180
|
+
var hasReportedState: Bool = false
|
|
180
181
|
var lastPositionMs: Int64 = 0
|
|
181
182
|
var lastDurationMs: Int64 = 0
|
|
182
183
|
var lastBufferedMs: Int64 = 0
|
|
@@ -186,8 +187,12 @@ final class ControllableRemoteSession: CastSessionRemotePlayer {
|
|
|
186
187
|
private var stateCb: ((RemotePlaybackStateKind) -> Void)?
|
|
187
188
|
private var positionCb: ((Int64, Int64, Int64) -> Void)?
|
|
188
189
|
|
|
190
|
+
/// Drive the receiver state the way a live session does: a non-idle report
|
|
191
|
+
/// latches `hasReportedState`. Set `lastState` directly to stage a state the
|
|
192
|
+
/// receiver has not reported yet.
|
|
189
193
|
func fireState(_ state: RemotePlaybackStateKind) {
|
|
190
194
|
lastState = state
|
|
195
|
+
if state != .idle { hasReportedState = true }
|
|
191
196
|
stateCb?(state)
|
|
192
197
|
}
|
|
193
198
|
|
|
@@ -30,6 +30,32 @@ final class GaplessEngineLifecycleTests: XCTestCase {
|
|
|
30
30
|
XCTAssertNil(engine.delegate)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// MARK: - Repeat mode
|
|
34
|
+
|
|
35
|
+
/// Repeat-one holds the current item at its end
|
|
36
|
+
/// (`actionAtItemEnd = .none`) so `TrackPlayer`'s end-of-track handler
|
|
37
|
+
/// can deterministically rewind + replay it; off / queue keep the
|
|
38
|
+
/// default `.advance`. Without the hold, the AVQueuePlayer advances
|
|
39
|
+
/// into the next enqueued item before the async rewind runs, so the
|
|
40
|
+
/// track skips forward instead of repeating.
|
|
41
|
+
func testRepeatTrackHoldsItemAtEndWhileOthersAdvance() {
|
|
42
|
+
let engine = GaplessEngine()
|
|
43
|
+
XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
|
|
44
|
+
|
|
45
|
+
engine.setRepeatMode(.track)
|
|
46
|
+
XCTAssertEqual(engine.player.actionAtItemEnd, .none)
|
|
47
|
+
|
|
48
|
+
engine.setRepeatMode(.queue)
|
|
49
|
+
XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
|
|
50
|
+
|
|
51
|
+
engine.setRepeatMode(.off)
|
|
52
|
+
XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
|
|
53
|
+
|
|
54
|
+
// Re-entering repeat-one re-applies the hold.
|
|
55
|
+
engine.setRepeatMode(.track)
|
|
56
|
+
XCTAssertEqual(engine.player.actionAtItemEnd, .none)
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
// MARK: - Queue-mutation surface
|
|
34
60
|
|
|
35
61
|
/// `setItems` installs the slice from `startIndex` onward; items
|
|
@@ -124,6 +124,7 @@ final class FakeRemoteSession: CastSessionRemotePlayer {
|
|
|
124
124
|
var isPushSink: Bool { false }
|
|
125
125
|
|
|
126
126
|
var lastState: RemotePlaybackStateKind = .idle
|
|
127
|
+
var hasReportedState: Bool = false
|
|
127
128
|
var lastPositionMs: Int64 = 0
|
|
128
129
|
var lastDurationMs: Int64 = 0
|
|
129
130
|
var lastBufferedMs: Int64 = 0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import QueuePlayer
|
|
3
|
+
|
|
4
|
+
/// `getState()` reports the receiver while a cast session owns playback. The
|
|
5
|
+
/// local engine is parked and paused for the session's duration, so reading it
|
|
6
|
+
/// would describe nothing the user can hear.
|
|
7
|
+
final class TrackPlayerCastStateTests: XCTestCase {
|
|
8
|
+
|
|
9
|
+
private let router = PlaybackStateRouter.shared
|
|
10
|
+
|
|
11
|
+
override func setUp() {
|
|
12
|
+
super.setUp()
|
|
13
|
+
router._resetForTesting()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override func tearDown() {
|
|
17
|
+
router._resetForTesting()
|
|
18
|
+
super.tearDown()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Register a session that has already reported `state` to the receiver.
|
|
22
|
+
private func activateReporting(_ state: RemotePlaybackStateKind) throws {
|
|
23
|
+
let session = ControllableRemoteSession()
|
|
24
|
+
session.fireState(state)
|
|
25
|
+
try router.setActive(session)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testReportsReceiverPlayingDuringCastSession() throws {
|
|
29
|
+
try activateReporting(.playing)
|
|
30
|
+
XCTAssertEqual(try TrackPlayer().getState(), .playing)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func testReportsReceiverPausedDuringCastSession() throws {
|
|
34
|
+
try activateReporting(.paused)
|
|
35
|
+
XCTAssertEqual(try TrackPlayer().getState(), .paused)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func testMapsBufferingReceiverToBuffering() throws {
|
|
39
|
+
try activateReporting(.buffering)
|
|
40
|
+
XCTAssertEqual(try TrackPlayer().getState(), .buffering)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func testMapsEndedReceiverToEnded() throws {
|
|
44
|
+
try activateReporting(.ended)
|
|
45
|
+
XCTAssertEqual(try TrackPlayer().getState(), .ended)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func testReportsIdleReceiverOnceItHasReported() throws {
|
|
49
|
+
// A receiver that has spoken stays authoritative: a later idle means it
|
|
50
|
+
// genuinely has nothing loaded, not that it is still activating.
|
|
51
|
+
let session = ControllableRemoteSession()
|
|
52
|
+
session.fireState(.playing)
|
|
53
|
+
session.fireState(.idle)
|
|
54
|
+
try router.setActive(session)
|
|
55
|
+
|
|
56
|
+
XCTAssertEqual(try TrackPlayer().getState(), PlayerState.none)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func testReadsLocalEngineUntilReceiverReports() throws {
|
|
60
|
+
// Session activating: the receiver holds a state it has not reported, so
|
|
61
|
+
// the local engine answers. Distinguishable from the receiver's `.playing`
|
|
62
|
+
// precisely because no engine is configured here.
|
|
63
|
+
let session = ControllableRemoteSession()
|
|
64
|
+
session.lastState = .playing
|
|
65
|
+
try router.setActive(session)
|
|
66
|
+
|
|
67
|
+
XCTAssertFalse(session.hasReportedState)
|
|
68
|
+
XCTAssertEqual(try TrackPlayer().getState(), PlayerState.none)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func testReadsLocalEngineWithNoCastSession() throws {
|
|
72
|
+
// An unregistered session must not be consulted, however it reports.
|
|
73
|
+
let session = ControllableRemoteSession()
|
|
74
|
+
session.fireState(.playing)
|
|
75
|
+
|
|
76
|
+
XCTAssertEqual(try TrackPlayer().getState(), PlayerState.none)
|
|
77
|
+
}
|
|
78
|
+
}
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -2968,6 +2968,15 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
2968
2968
|
// hop for consistency. `onMainSync` short-circuits when already
|
|
2969
2969
|
// on main, so callers that are on main don't deadlock.
|
|
2970
2970
|
func getState() throws -> PlayerState {
|
|
2971
|
+
// The receiver owns playback during a cast session — the local engine is
|
|
2972
|
+
// parked and paused for its duration, so its derived state describes
|
|
2973
|
+
// nothing the user can hear. Until the receiver has reported (session
|
|
2974
|
+
// still activating, holding its initial `.idle`) the local read is the
|
|
2975
|
+
// better answer; after that the receiver is authoritative, including a
|
|
2976
|
+
// later `.idle`, which means it genuinely has nothing loaded.
|
|
2977
|
+
if let remote = PlaybackStateRouter.shared.activeRemote(), remote.hasReportedState {
|
|
2978
|
+
return remote.lastState.asPlayerState
|
|
2979
|
+
}
|
|
2971
2980
|
return onMainSync {
|
|
2972
2981
|
// Both engines derive PlayerState from the same inputs via
|
|
2973
2982
|
// `engine.timeControlStatus` (gapless returns its AVQueuePlayer's, which
|
|
@@ -3952,10 +3961,12 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
3952
3961
|
/// pre-wrote.
|
|
3953
3962
|
///
|
|
3954
3963
|
/// Repeat-track handling: when `repeatModeState == .track`, seek
|
|
3955
|
-
/// the just-finished item back to zero + replay. The
|
|
3956
|
-
///
|
|
3957
|
-
///
|
|
3958
|
-
///
|
|
3964
|
+
/// the just-finished item back to zero + replay. The gapless engine
|
|
3965
|
+
/// holds the item at its end (`AVQueuePlayer.actionAtItemEnd = .none`
|
|
3966
|
+
/// under `.track`, set in `GaplessEngine.setRepeatMode`), so the
|
|
3967
|
+
/// player does not advance and `player.currentItem` is still the
|
|
3968
|
+
/// finished item when this async block runs — the rewind is
|
|
3969
|
+
/// deterministic. We do NOT set `didPlayToEndPending` in this branch —
|
|
3959
3970
|
/// the seek-to-zero produces no `\.currentItem` change, so there
|
|
3960
3971
|
/// is no auto-advance to gate.
|
|
3961
3972
|
@objc private func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
|
|
@@ -3968,30 +3979,21 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
3968
3979
|
let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
|
|
3969
3980
|
if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
|
|
3970
3981
|
// Branch on repeat-track FIRST, separately from the
|
|
3971
|
-
// currentItem === item check
|
|
3972
|
-
//
|
|
3973
|
-
// runs `actionAtItemEnd = .advance` synchronously on its own
|
|
3974
|
-
// queue — under load, the player can have already advanced
|
|
3975
|
-
// by the time our async block runs. Falling through to the
|
|
3976
|
-
// auto-advance branch in repeat-track mode would incorrectly
|
|
3977
|
-
// arm `didPlayToEndPending`; the resync would then run on
|
|
3978
|
-
// the unintended-advance currentItem and clobber the user's
|
|
3979
|
-
// expected repeat-track stay-on-current.
|
|
3982
|
+
// currentItem === item check, and never arm `didPlayToEndPending`
|
|
3983
|
+
// here — in repeat-track mode there is no auto-advance to gate.
|
|
3980
3984
|
if self.repeatModeState == .track {
|
|
3981
|
-
// Repeat-track: rewind the just-finished item + replay.
|
|
3982
|
-
//
|
|
3983
|
-
//
|
|
3984
|
-
//
|
|
3985
|
+
// Repeat-track: rewind the just-finished item + replay. The
|
|
3986
|
+
// gapless engine holds the item via `actionAtItemEnd = .none`
|
|
3987
|
+
// under `.track`, so the player does not advance and
|
|
3988
|
+
// `currentItem` is still the finished item here.
|
|
3985
3989
|
if let player = self.player {
|
|
3986
3990
|
// Repeat-one loop = a new milestone playthrough of the same track.
|
|
3987
3991
|
// The gapless engine fires no track-change / delegate here, so
|
|
3988
3992
|
// dispatchTrackChange's reset is skipped — reset directly, and
|
|
3989
|
-
// UNCONDITIONALLY for the gapless engine (self.player non-nil),
|
|
3990
|
-
//
|
|
3991
|
-
//
|
|
3992
|
-
//
|
|
3993
|
-
// re-arm regardless. (CrossfadeEngine has self.player == nil here and
|
|
3994
|
-
// resets via engineDidPlayItemToEnd instead — no double reset.)
|
|
3993
|
+
// UNCONDITIONALLY for the gapless engine (self.player non-nil), so
|
|
3994
|
+
// milestones re-arm for the new playthrough. (CrossfadeEngine has
|
|
3995
|
+
// self.player == nil here and resets via engineDidPlayItemToEnd
|
|
3996
|
+
// instead — no double reset.)
|
|
3995
3997
|
self.milestoneTracker.reset()
|
|
3996
3998
|
if let item = notification.object as? AVPlayerItem,
|
|
3997
3999
|
player.currentItem === item {
|
|
@@ -4003,15 +4005,13 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
4003
4005
|
if !sleepFired.pauseNow { self.engine?.play() }
|
|
4004
4006
|
}
|
|
4005
4007
|
}
|
|
4006
|
-
//
|
|
4007
|
-
//
|
|
4008
|
-
//
|
|
4009
|
-
//
|
|
4010
|
-
//
|
|
4011
|
-
//
|
|
4012
|
-
//
|
|
4013
|
-
// mutations pre-write the index; nothing overwrites the
|
|
4014
|
-
// shadow index incorrectly.
|
|
4008
|
+
// Defense in depth: if `currentItem` is somehow not the finished
|
|
4009
|
+
// item (e.g. a mode change raced this fire before `.none` was
|
|
4010
|
+
// applied), skip the rewind and let the `\.currentItem` KVO
|
|
4011
|
+
// re-sync `currentTrackIndex` — WITHOUT arming
|
|
4012
|
+
// `didPlayToEndPending`, since repeat-track should not advance.
|
|
4013
|
+
// User mutations pre-write the index, so the shadow index stays
|
|
4014
|
+
// correct.
|
|
4015
4015
|
return
|
|
4016
4016
|
}
|
|
4017
4017
|
// Repeat-off / repeat-queue: arm the resync gate so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-queue-player",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Nitro Modules audio playback library for React Native — gapless, EQ, crossfade, automotive, voice, casting",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/index.d.ts",
|
package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
package com.margelo.nitro.queueplayer.cast.airplay
|
|
2
|
-
|
|
3
|
-
import android.content.Context
|
|
4
|
-
import android.graphics.Bitmap
|
|
5
|
-
import android.graphics.BitmapFactory
|
|
6
|
-
import android.net.Uri
|
|
7
|
-
import android.os.Handler
|
|
8
|
-
import android.os.Looper
|
|
9
|
-
import android.util.Log
|
|
10
|
-
import androidx.media3.common.MediaItem
|
|
11
|
-
import androidx.media3.common.Player
|
|
12
|
-
import kotlinx.coroutines.*
|
|
13
|
-
import java.io.ByteArrayOutputStream
|
|
14
|
-
import java.net.URL
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Syncs ExoPlayer track metadata and playback progress to an [AirPlay2Session].
|
|
18
|
-
* Identical logic to [AirPlayMetadataSync] but targets the AP2 session.
|
|
19
|
-
*/
|
|
20
|
-
internal class AirPlay2MetadataSync(
|
|
21
|
-
private val context: Context,
|
|
22
|
-
private val session: AirPlay2Session,
|
|
23
|
-
private val player: Player,
|
|
24
|
-
private val scope: CoroutineScope,
|
|
25
|
-
) {
|
|
26
|
-
companion object {
|
|
27
|
-
private const val TAG = "AP2MetadataSync"
|
|
28
|
-
private const val PROGRESS_INTERVAL_MS = 5000L
|
|
29
|
-
private const val MAX_ARTWORK_DIM = 600
|
|
30
|
-
private const val JPEG_QUALITY = 80
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
private var progressJob: Job? = null
|
|
34
|
-
private var artworkJob: Job? = null
|
|
35
|
-
private var lastArtworkUri: String? = null
|
|
36
|
-
private var started = false
|
|
37
|
-
|
|
38
|
-
// ExoPlayer rejects add/removeListener + state reads off its application
|
|
39
|
-
// looper. start()/stop() are driven by session activation/close, and
|
|
40
|
-
// close() runs on whichever thread disconnected (the JS thread for a
|
|
41
|
-
// user disconnect, the native drain thread on receiver death) — so the
|
|
42
|
-
// player touches are marshalled onto the player's looper, run inline when
|
|
43
|
-
// already on it.
|
|
44
|
-
private val playerHandler = Handler(player.applicationLooper)
|
|
45
|
-
|
|
46
|
-
private fun onPlayerLooper(block: () -> Unit) {
|
|
47
|
-
if (Looper.myLooper() == player.applicationLooper) block()
|
|
48
|
-
else playerHandler.post(block)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
private val listener = object : Player.Listener {
|
|
52
|
-
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
53
|
-
sendCurrentMetadata()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
override fun onPositionDiscontinuity(
|
|
57
|
-
oldPosition: Player.PositionInfo,
|
|
58
|
-
newPosition: Player.PositionInfo,
|
|
59
|
-
reason: Int,
|
|
60
|
-
) {
|
|
61
|
-
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
|
62
|
-
sendProgress()
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
|
67
|
-
if (playWhenReady) {
|
|
68
|
-
startProgressTimer()
|
|
69
|
-
} else {
|
|
70
|
-
progressJob?.cancel()
|
|
71
|
-
progressJob = null
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
fun start() {
|
|
77
|
-
if (started) return
|
|
78
|
-
started = true
|
|
79
|
-
onPlayerLooper {
|
|
80
|
-
player.addListener(listener)
|
|
81
|
-
sendCurrentMetadata()
|
|
82
|
-
if (player.playWhenReady) {
|
|
83
|
-
startProgressTimer()
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
Log.i(TAG, "Started metadata sync")
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
fun stop() {
|
|
90
|
-
if (!started) return
|
|
91
|
-
started = false
|
|
92
|
-
progressJob?.cancel()
|
|
93
|
-
progressJob = null
|
|
94
|
-
artworkJob?.cancel()
|
|
95
|
-
artworkJob = null
|
|
96
|
-
onPlayerLooper { player.removeListener(listener) }
|
|
97
|
-
Log.i(TAG, "Stopped metadata sync")
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private fun sendCurrentMetadata() {
|
|
101
|
-
val meta = player.currentMediaItem?.mediaMetadata ?: return
|
|
102
|
-
val title = meta.title?.toString()
|
|
103
|
-
val artist = meta.artist?.toString()
|
|
104
|
-
val album = meta.albumTitle?.toString()
|
|
105
|
-
|
|
106
|
-
Log.i(TAG, "Sending metadata: title=$title, artist=$artist, album=$album")
|
|
107
|
-
session.setMetadata(title, artist, album)
|
|
108
|
-
sendProgress()
|
|
109
|
-
|
|
110
|
-
val artUri = meta.artworkUri?.toString()
|
|
111
|
-
if (artUri != null && artUri != lastArtworkUri) {
|
|
112
|
-
lastArtworkUri = artUri
|
|
113
|
-
fetchAndSendArtwork(artUri)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private fun sendProgress() {
|
|
118
|
-
val position = player.currentPosition.toInt()
|
|
119
|
-
val duration = player.duration.let { if (it > 0) it.toInt() else 0 }
|
|
120
|
-
if (duration > 0) {
|
|
121
|
-
session.setProgress(position, duration)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
private fun startProgressTimer() {
|
|
126
|
-
progressJob?.cancel()
|
|
127
|
-
progressJob = scope.launch {
|
|
128
|
-
while (isActive) {
|
|
129
|
-
delay(PROGRESS_INTERVAL_MS)
|
|
130
|
-
if (player.isPlaying) {
|
|
131
|
-
sendProgress()
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private fun fetchAndSendArtwork(uriString: String) {
|
|
138
|
-
artworkJob?.cancel()
|
|
139
|
-
artworkJob = scope.launch(Dispatchers.IO) {
|
|
140
|
-
try {
|
|
141
|
-
val uri = Uri.parse(uriString)
|
|
142
|
-
val scheme = uri.scheme ?: return@launch
|
|
143
|
-
|
|
144
|
-
// Read raw bytes — use ContentResolver for content:// and
|
|
145
|
-
// android.resource://, URL for http(s), file for file://
|
|
146
|
-
val rawBytes: ByteArray = when (scheme) {
|
|
147
|
-
"content", "android.resource" ->
|
|
148
|
-
context.contentResolver.openInputStream(uri)
|
|
149
|
-
?.use { it.readBytes() } ?: return@launch
|
|
150
|
-
"file" ->
|
|
151
|
-
java.io.File(uri.path ?: return@launch).readBytes()
|
|
152
|
-
"http", "https" ->
|
|
153
|
-
URL(uriString).openStream().use { it.readBytes() }
|
|
154
|
-
else -> {
|
|
155
|
-
Log.w(TAG, "Unsupported artwork scheme: $scheme")
|
|
156
|
-
return@launch
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
val boundsOpts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
161
|
-
BitmapFactory.decodeByteArray(rawBytes, 0, rawBytes.size, boundsOpts)
|
|
162
|
-
|
|
163
|
-
var sampleSize = 1
|
|
164
|
-
while (boundsOpts.outWidth / sampleSize > MAX_ARTWORK_DIM ||
|
|
165
|
-
boundsOpts.outHeight / sampleSize > MAX_ARTWORK_DIM) {
|
|
166
|
-
sampleSize *= 2
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
val decodeOpts = BitmapFactory.Options().apply { inSampleSize = sampleSize }
|
|
170
|
-
val bitmap = BitmapFactory.decodeByteArray(rawBytes, 0, rawBytes.size, decodeOpts)
|
|
171
|
-
?: return@launch
|
|
172
|
-
|
|
173
|
-
val baos = ByteArrayOutputStream()
|
|
174
|
-
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos)
|
|
175
|
-
bitmap.recycle()
|
|
176
|
-
|
|
177
|
-
val bytes = baos.toByteArray()
|
|
178
|
-
if (isActive) {
|
|
179
|
-
session.setArtwork(bytes, "image/jpeg")
|
|
180
|
-
Log.i(TAG, "Sent artwork: ${bytes.size} bytes from $uriString")
|
|
181
|
-
}
|
|
182
|
-
} catch (e: Exception) {
|
|
183
|
-
if (e !is CancellationException) {
|
|
184
|
-
Log.w(TAG, "Artwork fetch failed for $uriString", e)
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|