expo-gliph-player 1.0.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/LICENSE +21 -0
- package/README.md +348 -0
- package/android/build.gradle +82 -0
- package/android/src/main/AndroidManifest.xml +31 -0
- package/android/src/main/java/com/gliphplayer/DeviceInfoModule.kt +48 -0
- package/android/src/main/java/com/gliphplayer/DeviceInfoPackage.kt +16 -0
- package/android/src/main/java/com/gliphplayer/GliphPlayerModule.kt +360 -0
- package/android/src/main/java/com/gliphplayer/GliphPlayerPackage.kt +48 -0
- package/android/src/main/java/com/gliphplayer/GliphPlayerService.kt +797 -0
- package/android/src/main/res/xml/automotive_app_desc.xml +4 -0
- package/android/src/oldarch/com/gliphplayer/NativeGliphPlayerSpec.kt +56 -0
- package/app.plugin.js +1 -0
- package/expo-gliph-player.podspec +91 -0
- package/ios/GliphAudioPlayer.h +77 -0
- package/ios/GliphAudioPlayer.swift +697 -0
- package/ios/GliphPlayer-Bridging-Header.h +3 -0
- package/ios/GliphPlayerModule.h +12 -0
- package/ios/GliphPlayerModule.mm +243 -0
- package/ios/expo_gliph_player.h +22 -0
- package/lib/commonjs/GliphPlayer.js +310 -0
- package/lib/commonjs/GliphPlayer.js.map +1 -0
- package/lib/commonjs/hooks.js +233 -0
- package/lib/commonjs/hooks.js.map +1 -0
- package/lib/commonjs/index.js +166 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/specs/NativeGliphPlayer.js +19 -0
- package/lib/commonjs/specs/NativeGliphPlayer.js.map +1 -0
- package/lib/commonjs/types.js +175 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/GliphPlayer.js +305 -0
- package/lib/module/GliphPlayer.js.map +1 -0
- package/lib/module/hooks.js +221 -0
- package/lib/module/hooks.js.map +1 -0
- package/lib/module/index.js +20 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/specs/NativeGliphPlayer.js +20 -0
- package/lib/module/specs/NativeGliphPlayer.js.map +1 -0
- package/lib/module/types.js +181 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/src/GliphPlayer.d.ts +113 -0
- package/lib/typescript/src/GliphPlayer.d.ts.map +1 -0
- package/lib/typescript/src/hooks.d.ts +51 -0
- package/lib/typescript/src/hooks.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeGliphPlayer.d.ts +80 -0
- package/lib/typescript/src/specs/NativeGliphPlayer.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +348 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/GliphPlayer.ts +356 -0
- package/src/hooks.ts +256 -0
- package/src/index.ts +61 -0
- package/src/specs/NativeGliphPlayer.ts +105 -0
- package/src/types.ts +395 -0
- package/src/withGliphPlayer.js +166 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
package com.gliphplayer
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
|
|
5
|
+
import android.app.Notification
|
|
6
|
+
import android.app.NotificationManager
|
|
7
|
+
import android.app.PendingIntent
|
|
8
|
+
import android.content.Context
|
|
9
|
+
import android.content.Intent
|
|
10
|
+
import android.os.Binder
|
|
11
|
+
import android.os.IBinder
|
|
12
|
+
import androidx.media3.common.*
|
|
13
|
+
import androidx.media3.common.util.UnstableApi
|
|
14
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
15
|
+
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
16
|
+
import androidx.media3.session.*
|
|
17
|
+
import androidx.core.app.NotificationCompat
|
|
18
|
+
import android.content.pm.ServiceInfo
|
|
19
|
+
import com.facebook.react.bridge.*
|
|
20
|
+
import kotlinx.coroutines.*
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GliphPlayerService
|
|
24
|
+
*
|
|
25
|
+
* Foreground service that owns the ExoPlayer instance and MediaSession.
|
|
26
|
+
* Survives app backgrounding and handles:
|
|
27
|
+
* - Audio playback via ExoPlayer (Media3)
|
|
28
|
+
* - MediaSession for lock screen / notification controls
|
|
29
|
+
* - Android Auto via MediaLibraryService
|
|
30
|
+
* - Audio focus management
|
|
31
|
+
* - Queue management
|
|
32
|
+
*/
|
|
33
|
+
@UnstableApi
|
|
34
|
+
class GliphPlayerService : MediaLibraryService() {
|
|
35
|
+
|
|
36
|
+
companion object {
|
|
37
|
+
private const val CHANNEL_ID = "gliph_player_channel"
|
|
38
|
+
private const val NOTIFICATION_ID = 1001
|
|
39
|
+
private const val ACTION_PLAY = "com.gliphplayer.action.PLAY"
|
|
40
|
+
private const val ACTION_PAUSE = "com.gliphplayer.action.PAUSE"
|
|
41
|
+
private const val ACTION_SKIP_NEXT = "com.gliphplayer.action.SKIP_NEXT"
|
|
42
|
+
private const val ACTION_SKIP_PREVIOUS = "com.gliphplayer.action.SKIP_PREVIOUS"
|
|
43
|
+
private const val ACTION_STOP = "com.gliphplayer.action.STOP"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Binder ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
inner class LocalBinder : Binder() {
|
|
49
|
+
fun getService(): GliphPlayerService = this@GliphPlayerService
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private val binder = LocalBinder()
|
|
53
|
+
|
|
54
|
+
override fun onBind(intent: Intent?): IBinder? {
|
|
55
|
+
// MediaLibraryService.onBind handles media browser connections.
|
|
56
|
+
// For our local binding from GliphPlayerModule, we return our LocalBinder.
|
|
57
|
+
// We must check the action to distinguish the two callers.
|
|
58
|
+
val action = intent?.action
|
|
59
|
+
if (action == null ||
|
|
60
|
+
action == "com.gliphplayer.BIND_LOCAL" ||
|
|
61
|
+
action == Intent.ACTION_MAIN) {
|
|
62
|
+
return binder
|
|
63
|
+
}
|
|
64
|
+
// Let MediaLibraryService handle MediaBrowser / MediaSession connections
|
|
65
|
+
return super.onBind(intent)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── State ───────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
private lateinit var player: ExoPlayer
|
|
71
|
+
private lateinit var mediaSession: MediaLibrarySession
|
|
72
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
73
|
+
|
|
74
|
+
private var eventEmitter: ((String, WritableMap?) -> Unit)? = null
|
|
75
|
+
private var progressJob: Job? = null
|
|
76
|
+
private var options: ReadableMap? = null
|
|
77
|
+
|
|
78
|
+
// Internal queue (mirrors ExoPlayer's playlist)
|
|
79
|
+
private val queue = java.util.Collections.synchronizedList(mutableListOf<ReadableMap>())
|
|
80
|
+
|
|
81
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
private fun getDouble(map: ReadableMap?, key: String, default: Double): Double {
|
|
84
|
+
return if (map?.hasKey(key) == true) map.getDouble(key) else default
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private fun getInt(map: ReadableMap?, key: String, default: Int): Int {
|
|
88
|
+
return if (map?.hasKey(key) == true) map.getInt(key) else default
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun getString(map: ReadableMap?, key: String, default: String?): String? {
|
|
92
|
+
return if (map?.hasKey(key) == true) map.getString(key) else default
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
override fun onCreate() {
|
|
98
|
+
super.onCreate()
|
|
99
|
+
// 1. Create high-importance notification channel
|
|
100
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
|
101
|
+
val channel = android.app.NotificationChannel(
|
|
102
|
+
CHANNEL_ID,
|
|
103
|
+
"Music Playback",
|
|
104
|
+
android.app.NotificationManager.IMPORTANCE_HIGH
|
|
105
|
+
).apply {
|
|
106
|
+
description = "Controls for music playback"
|
|
107
|
+
setShowBadge(false)
|
|
108
|
+
setSound(null, null)
|
|
109
|
+
}
|
|
110
|
+
val manager = getSystemService(android.app.NotificationManager::class.java)
|
|
111
|
+
manager.createNotificationChannel(channel)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Immediate Foregrounding with a safe system icon
|
|
115
|
+
val initialNotification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
116
|
+
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
117
|
+
.setContentTitle("Gliph Player")
|
|
118
|
+
.setContentText("Preparing playback...")
|
|
119
|
+
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
120
|
+
.setOngoing(true)
|
|
121
|
+
.build()
|
|
122
|
+
|
|
123
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
124
|
+
startForeground(NOTIFICATION_ID, initialNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
|
125
|
+
} else {
|
|
126
|
+
startForeground(NOTIFICATION_ID, initialNotification)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Initialize player
|
|
130
|
+
initPlayer()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
134
|
+
super.onStartCommand(intent, flags, startId)
|
|
135
|
+
when (intent?.action) {
|
|
136
|
+
ACTION_PLAY -> play()
|
|
137
|
+
ACTION_PAUSE -> pause()
|
|
138
|
+
ACTION_SKIP_NEXT -> skipToNext(-1.0)
|
|
139
|
+
ACTION_SKIP_PREVIOUS -> skipToPrevious(-1.0)
|
|
140
|
+
ACTION_STOP -> {
|
|
141
|
+
stop()
|
|
142
|
+
stopSelf()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return START_STICKY
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override fun onDestroy() {
|
|
149
|
+
scope.cancel()
|
|
150
|
+
mediaSession.release()
|
|
151
|
+
player.release()
|
|
152
|
+
super.onDestroy()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
|
|
156
|
+
Log.d("GliphPlayer", "onGetSession called from: ${controllerInfo.packageName}")
|
|
157
|
+
return mediaSession
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
161
|
+
val behavior = options?.getMap("android")?.getString("appKilledPlaybackBehavior")
|
|
162
|
+
if (behavior == "StopPlaybackAndRemoveNotification") {
|
|
163
|
+
destroy()
|
|
164
|
+
stopSelf()
|
|
165
|
+
} else if (behavior == "PausePlayback") {
|
|
166
|
+
player.pause()
|
|
167
|
+
}
|
|
168
|
+
super.onTaskRemoved(rootIntent)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Init ────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
private fun initPlayer(opts: ReadableMap? = null) {
|
|
174
|
+
// ── Fix #4: Buffer values from JS are in SECONDS (Double).
|
|
175
|
+
// ExoPlayer's DefaultLoadControl expects MILLISECONDS (Long).
|
|
176
|
+
// We convert here so callers never accidentally buffer for 1000 seconds.
|
|
177
|
+
//
|
|
178
|
+
// JS API contract (all values in seconds):
|
|
179
|
+
// minBuffer — minimum seconds to buffer before playback starts (default 15s)
|
|
180
|
+
// maxBuffer — maximum seconds to buffer ahead (default 50s)
|
|
181
|
+
// playBuffer — seconds buffered before playback resumes after stall (default 2.5s)
|
|
182
|
+
// backBuffer — seconds of audio to keep behind current position (default 0s)
|
|
183
|
+
val minBufferMs = (getDouble(opts, "minBuffer", 15.0) * 1000).toInt()
|
|
184
|
+
val maxBufferMs = (getDouble(opts, "maxBuffer", 50.0) * 1000).toInt()
|
|
185
|
+
val playBufferMs = (getDouble(opts, "playBuffer", 2.5) * 1000).toInt()
|
|
186
|
+
val backBufferMs = (getDouble(opts, "backBuffer", 0.0) * 1000).toInt()
|
|
187
|
+
|
|
188
|
+
val loadControl = androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
|
189
|
+
.setBufferDurationsMs(minBufferMs, maxBufferMs, playBufferMs, playBufferMs)
|
|
190
|
+
.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe= */ false)
|
|
191
|
+
.build()
|
|
192
|
+
|
|
193
|
+
player = ExoPlayer.Builder(this)
|
|
194
|
+
.setMediaSourceFactory(DefaultMediaSourceFactory(this))
|
|
195
|
+
.setLoadControl(loadControl)
|
|
196
|
+
.setHandleAudioBecomingNoisy(true)
|
|
197
|
+
.setWakeMode(C.WAKE_MODE_NETWORK)
|
|
198
|
+
.setAudioAttributes(
|
|
199
|
+
AudioAttributes.Builder()
|
|
200
|
+
.setUsage(C.USAGE_MEDIA)
|
|
201
|
+
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
202
|
+
.build(),
|
|
203
|
+
/* handleAudioFocus= */ true
|
|
204
|
+
)
|
|
205
|
+
.build()
|
|
206
|
+
|
|
207
|
+
player.addListener(playerListener)
|
|
208
|
+
|
|
209
|
+
val sessionActivityIntent = packageManager
|
|
210
|
+
.getLaunchIntentForPackage(packageName)
|
|
211
|
+
?.let { PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
|
212
|
+
|
|
213
|
+
mediaSession = MediaLibrarySession.Builder(this, player, mediaSessionCallback)
|
|
214
|
+
.also { builder ->
|
|
215
|
+
sessionActivityIntent?.let { builder.setSessionActivity(it) }
|
|
216
|
+
}
|
|
217
|
+
.build()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun servicePendingIntent(action: String, requestCode: Int): PendingIntent {
|
|
221
|
+
val intent = Intent(this, GliphPlayerService::class.java).apply {
|
|
222
|
+
this.action = action
|
|
223
|
+
}
|
|
224
|
+
return PendingIntent.getService(
|
|
225
|
+
this,
|
|
226
|
+
requestCode,
|
|
227
|
+
intent,
|
|
228
|
+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private fun appPendingIntent(): PendingIntent? {
|
|
233
|
+
return packageManager
|
|
234
|
+
.getLaunchIntentForPackage(packageName)
|
|
235
|
+
?.let { intent ->
|
|
236
|
+
PendingIntent.getActivity(
|
|
237
|
+
this,
|
|
238
|
+
0,
|
|
239
|
+
intent,
|
|
240
|
+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private fun buildMediaNotification(): Notification {
|
|
246
|
+
val currentMediaItem = player.currentMediaItem
|
|
247
|
+
val metadata = currentMediaItem?.mediaMetadata
|
|
248
|
+
val playPauseIcon = if (player.isPlaying) {
|
|
249
|
+
android.R.drawable.ic_media_pause
|
|
250
|
+
} else {
|
|
251
|
+
android.R.drawable.ic_media_play
|
|
252
|
+
}
|
|
253
|
+
val playPauseTitle = if (player.isPlaying) "Pause" else "Play"
|
|
254
|
+
val playPauseAction = if (player.isPlaying) ACTION_PAUSE else ACTION_PLAY
|
|
255
|
+
|
|
256
|
+
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
257
|
+
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
258
|
+
.setContentTitle(metadata?.title ?: "Gliph Player")
|
|
259
|
+
.setContentText(metadata?.artist ?: "Ready to play")
|
|
260
|
+
.setSubText(metadata?.albumTitle)
|
|
261
|
+
.setContentIntent(appPendingIntent())
|
|
262
|
+
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
263
|
+
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
|
264
|
+
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
265
|
+
.setOnlyAlertOnce(true)
|
|
266
|
+
.setSilent(true)
|
|
267
|
+
.setOngoing(player.isPlaying)
|
|
268
|
+
.addAction(
|
|
269
|
+
android.R.drawable.ic_media_previous,
|
|
270
|
+
"Previous",
|
|
271
|
+
servicePendingIntent(ACTION_SKIP_PREVIOUS, 1)
|
|
272
|
+
)
|
|
273
|
+
.addAction(
|
|
274
|
+
playPauseIcon,
|
|
275
|
+
playPauseTitle,
|
|
276
|
+
servicePendingIntent(playPauseAction, 2)
|
|
277
|
+
)
|
|
278
|
+
.addAction(
|
|
279
|
+
android.R.drawable.ic_media_next,
|
|
280
|
+
"Next",
|
|
281
|
+
servicePendingIntent(ACTION_SKIP_NEXT, 3)
|
|
282
|
+
)
|
|
283
|
+
.setStyle(
|
|
284
|
+
androidx.media.app.NotificationCompat.MediaStyle()
|
|
285
|
+
.setMediaSession(mediaSession.sessionCompatToken)
|
|
286
|
+
.setShowActionsInCompactView(0, 1, 2)
|
|
287
|
+
)
|
|
288
|
+
.build()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private fun updateMediaNotification() {
|
|
292
|
+
if (!::player.isInitialized || !::mediaSession.isInitialized) {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
val notification = buildMediaNotification()
|
|
297
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
298
|
+
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
|
299
|
+
} else {
|
|
300
|
+
startForeground(NOTIFICATION_ID, notification)
|
|
301
|
+
}
|
|
302
|
+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
303
|
+
manager.notify(NOTIFICATION_ID, notification)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
fun setEventEmitter(emitter: (String, WritableMap?) -> Unit) {
|
|
307
|
+
eventEmitter = emitter
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Setup ───────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
fun setupPlayer(opts: ReadableMap) {
|
|
313
|
+
Log.d("GliphPlayer", "setupPlayer called with opts: $opts")
|
|
314
|
+
options = opts
|
|
315
|
+
|
|
316
|
+
// ── Fix #5: Android-specific options (appKilledPlaybackBehavior, audioUsage,
|
|
317
|
+
// audioContentType) are applied HERE at setup time, not in updateOptions.
|
|
318
|
+
// This avoids Codegen type mismatches and ensures the ExoPlayer AudioAttributes
|
|
319
|
+
// are configured before the first track is loaded.
|
|
320
|
+
val androidOpts = if (opts.hasKey("android")) opts.getMap("android") else null
|
|
321
|
+
val audioUsageStr = getString(androidOpts, "audioUsage", null)
|
|
322
|
+
val audioUsage = when (audioUsageStr) {
|
|
323
|
+
"voiceCommunication" -> C.USAGE_VOICE_COMMUNICATION
|
|
324
|
+
"alarm" -> C.USAGE_ALARM
|
|
325
|
+
"notification" -> C.USAGE_NOTIFICATION
|
|
326
|
+
"game" -> C.USAGE_GAME
|
|
327
|
+
else -> C.USAGE_MEDIA // default: music
|
|
328
|
+
}
|
|
329
|
+
val audioContentTypeStr = getString(androidOpts, "audioContentType", null)
|
|
330
|
+
val audioContentType = when (audioContentTypeStr) {
|
|
331
|
+
"speech" -> C.AUDIO_CONTENT_TYPE_SPEECH
|
|
332
|
+
"sonification" -> C.AUDIO_CONTENT_TYPE_SONIFICATION
|
|
333
|
+
"movie" -> C.AUDIO_CONTENT_TYPE_MOVIE
|
|
334
|
+
else -> C.AUDIO_CONTENT_TYPE_MUSIC // default
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Re-init player with the correct buffer settings and audio attributes
|
|
338
|
+
if (::player.isInitialized) {
|
|
339
|
+
player.release()
|
|
340
|
+
mediaSession.release()
|
|
341
|
+
}
|
|
342
|
+
initPlayer(opts)
|
|
343
|
+
|
|
344
|
+
// Apply audio attributes after init (ExoPlayer allows this before first play)
|
|
345
|
+
player.setAudioAttributes(
|
|
346
|
+
AudioAttributes.Builder()
|
|
347
|
+
.setUsage(audioUsage)
|
|
348
|
+
.setContentType(audioContentType)
|
|
349
|
+
.build(),
|
|
350
|
+
/* handleAudioFocus= */ true
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
startProgressUpdates()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fun destroy() {
|
|
357
|
+
stopProgressUpdates()
|
|
358
|
+
scope.launch(Dispatchers.Main) {
|
|
359
|
+
if (::player.isInitialized) {
|
|
360
|
+
player.stop()
|
|
361
|
+
player.clearMediaItems()
|
|
362
|
+
}
|
|
363
|
+
synchronized(queue) {
|
|
364
|
+
queue.clear()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Queue management ────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
fun add(tracks: ReadableArray, insertBeforeIndex: Int): Int {
|
|
372
|
+
val startIndex = if (insertBeforeIndex < 0 || insertBeforeIndex > queue.size) {
|
|
373
|
+
queue.size
|
|
374
|
+
} else {
|
|
375
|
+
insertBeforeIndex
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
val mediaItems = mutableListOf<MediaItem>()
|
|
379
|
+
for (i in 0 until tracks.size()) {
|
|
380
|
+
val track = tracks.getMap(i) ?: continue
|
|
381
|
+
Log.d("GliphPlayer", "Adding track: ${track.getString("title")} (URL: ${track.getString("url")})")
|
|
382
|
+
queue.add(startIndex + (i), track)
|
|
383
|
+
mediaItems.add(buildMediaItem(track))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (insertBeforeIndex < 0 || insertBeforeIndex >= player.mediaItemCount) {
|
|
387
|
+
player.addMediaItems(mediaItems)
|
|
388
|
+
} else {
|
|
389
|
+
player.addMediaItems(insertBeforeIndex, mediaItems)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
updateMediaNotification()
|
|
393
|
+
return startIndex
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fun remove(trackIds: ReadableArray) {
|
|
397
|
+
val ids = (0 until trackIds.size()).map { trackIds.getString(it) ?: "" }.toSet()
|
|
398
|
+
val indicesToRemove = queue.indices
|
|
399
|
+
.filter { i -> queue[i].getString("id") in ids }
|
|
400
|
+
.sortedDescending()
|
|
401
|
+
|
|
402
|
+
for (i in indicesToRemove) {
|
|
403
|
+
queue.removeAt(i)
|
|
404
|
+
player.removeMediaItem(i)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fun removeUpcomingTracks() {
|
|
409
|
+
val current = player.currentMediaItemIndex
|
|
410
|
+
val total = player.mediaItemCount
|
|
411
|
+
if (current < total - 1) {
|
|
412
|
+
player.removeMediaItems(current + 1, total)
|
|
413
|
+
while (queue.size > current + 1) {
|
|
414
|
+
queue.removeAt(queue.size - 1)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fun skip(index: Int, initialPosition: Double) {
|
|
420
|
+
player.seekTo(index, if (initialPosition >= 0) (initialPosition * 1000).toLong() else C.TIME_UNSET)
|
|
421
|
+
player.play()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fun skipToNext(initialPosition: Double) {
|
|
425
|
+
if (player.hasNextMediaItem()) {
|
|
426
|
+
player.seekToNextMediaItem()
|
|
427
|
+
if (initialPosition >= 0) player.seekTo((initialPosition * 1000).toLong())
|
|
428
|
+
player.play()
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fun skipToPrevious(initialPosition: Double) {
|
|
433
|
+
if (player.hasPreviousMediaItem()) {
|
|
434
|
+
player.seekToPreviousMediaItem()
|
|
435
|
+
if (initialPosition >= 0) player.seekTo((initialPosition * 1000).toLong())
|
|
436
|
+
player.play()
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
fun move(fromIndex: Int, toIndex: Int) {
|
|
441
|
+
if (fromIndex < 0 || fromIndex >= queue.size || toIndex < 0 || toIndex >= queue.size) return
|
|
442
|
+
val track = queue.removeAt(fromIndex)
|
|
443
|
+
queue.add(toIndex, track)
|
|
444
|
+
player.moveMediaItem(fromIndex, toIndex)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── Playback control ────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
fun play() {
|
|
450
|
+
if (player.playbackState == Player.STATE_IDLE) {
|
|
451
|
+
player.prepare()
|
|
452
|
+
}
|
|
453
|
+
player.play()
|
|
454
|
+
updateMediaNotification()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
fun pause() {
|
|
458
|
+
player.pause()
|
|
459
|
+
updateMediaNotification()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fun stop() {
|
|
463
|
+
player.stop()
|
|
464
|
+
updateMediaNotification()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
fun reset() {
|
|
468
|
+
player.stop()
|
|
469
|
+
player.clearMediaItems()
|
|
470
|
+
queue.clear()
|
|
471
|
+
updateMediaNotification()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
fun seekTo(positionSeconds: Double) {
|
|
475
|
+
player.seekTo((positionSeconds * 1000).toLong())
|
|
476
|
+
val map = Arguments.createMap()
|
|
477
|
+
map.putDouble("position", positionSeconds)
|
|
478
|
+
map.putDouble("duration", if (player.duration == C.TIME_UNSET) 0.0 else player.duration / 1000.0)
|
|
479
|
+
map.putDouble("buffered", player.bufferedPosition / 1000.0)
|
|
480
|
+
map.putInt("track", player.currentMediaItemIndex)
|
|
481
|
+
eventEmitter?.invoke("playback-progress-updated", map)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
fun seekBy(offsetSeconds: Double) {
|
|
485
|
+
val newPos = player.currentPosition + (offsetSeconds * 1000).toLong()
|
|
486
|
+
player.seekTo(newPos.coerceAtLeast(0))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
fun setVolume(volume: Float) {
|
|
490
|
+
player.volume = volume
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
fun getVolume(): Float = player.volume
|
|
494
|
+
|
|
495
|
+
fun setRate(rate: Float) {
|
|
496
|
+
player.setPlaybackSpeed(rate)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fun getRate(): Float = player.playbackParameters.speed
|
|
500
|
+
|
|
501
|
+
fun setRepeatMode(mode: Int) {
|
|
502
|
+
player.repeatMode = when (mode) {
|
|
503
|
+
1 -> Player.REPEAT_MODE_ONE
|
|
504
|
+
2 -> Player.REPEAT_MODE_ALL
|
|
505
|
+
else -> Player.REPEAT_MODE_OFF
|
|
506
|
+
}
|
|
507
|
+
val map = Arguments.createMap()
|
|
508
|
+
map.putInt("mode", mode)
|
|
509
|
+
eventEmitter?.invoke("playback-repeat-mode-changed", map)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
fun getRepeatMode(): Int = when (player.repeatMode) {
|
|
513
|
+
Player.REPEAT_MODE_ONE -> 1
|
|
514
|
+
Player.REPEAT_MODE_ALL -> 2
|
|
515
|
+
else -> 0
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── Queue getters ───────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
fun getQueue(): WritableArray {
|
|
521
|
+
val arr = Arguments.createArray()
|
|
522
|
+
queue.forEach { track ->
|
|
523
|
+
arr.pushMap(Arguments.makeNativeMap(track.toHashMap()))
|
|
524
|
+
}
|
|
525
|
+
return arr
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
fun getActiveTrackIndex(): Int {
|
|
529
|
+
val idx = player.currentMediaItemIndex
|
|
530
|
+
return if (idx >= 0 && idx < queue.size) idx else -1
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
fun getActiveTrack(): WritableMap? {
|
|
534
|
+
val idx = getActiveTrackIndex()
|
|
535
|
+
if (idx < 0 || idx >= queue.size) return null
|
|
536
|
+
return Arguments.makeNativeMap(queue[idx].toHashMap())
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
fun getTrack(index: Int): WritableMap? {
|
|
540
|
+
if (index < 0 || index >= queue.size) return null
|
|
541
|
+
return Arguments.makeNativeMap(queue[index].toHashMap())
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
fun getQueueSize(): Int = queue.size
|
|
545
|
+
|
|
546
|
+
// ── State / progress ────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
fun getPlaybackState(): WritableMap {
|
|
549
|
+
val map = Arguments.createMap()
|
|
550
|
+
map.putString("state", mapPlayerState())
|
|
551
|
+
return map
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
fun getProgress(): WritableMap {
|
|
555
|
+
val map = Arguments.createMap()
|
|
556
|
+
map.putDouble("position", player.currentPosition / 1000.0)
|
|
557
|
+
map.putDouble("duration", if (player.duration == C.TIME_UNSET) 0.0 else player.duration / 1000.0)
|
|
558
|
+
map.putDouble("buffered", player.bufferedPosition / 1000.0)
|
|
559
|
+
return map
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private fun mapPlayerState(): String {
|
|
563
|
+
if (player.playerError != null) return "error"
|
|
564
|
+
if (!player.playWhenReady && player.playbackState == Player.STATE_READY) return "paused"
|
|
565
|
+
return when (player.playbackState) {
|
|
566
|
+
Player.STATE_IDLE -> "none"
|
|
567
|
+
Player.STATE_BUFFERING -> if (player.playWhenReady) "buffering" else "loading"
|
|
568
|
+
Player.STATE_READY -> if (player.isPlaying) "playing" else "paused"
|
|
569
|
+
Player.STATE_ENDED -> "ended"
|
|
570
|
+
else -> "none"
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── Metadata ────────────────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
fun updateMetadataForTrack(index: Int, metadata: ReadableMap) {
|
|
577
|
+
if (index < 0 || index >= queue.size) return
|
|
578
|
+
val existing = queue[index].toHashMap()
|
|
579
|
+
metadata.toHashMap().forEach { (k, v) -> existing[k] = v }
|
|
580
|
+
// Rebuild the queue entry (ReadableMap is immutable, so we use a WritableMap)
|
|
581
|
+
val updated = Arguments.makeNativeMap(existing)
|
|
582
|
+
queue[index] = updated
|
|
583
|
+
// Update ExoPlayer media item metadata
|
|
584
|
+
player.replaceMediaItem(index, buildMediaItem(updated))
|
|
585
|
+
updateMediaNotification()
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
fun clearNowPlayingMetadata() {
|
|
589
|
+
mediaSession.setCustomLayout(emptyList())
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fun updateNowPlayingMetadata(metadata: ReadableMap) {
|
|
593
|
+
val currentIndex = player.currentMediaItemIndex
|
|
594
|
+
if (currentIndex < 0 || currentIndex >= queue.size) return
|
|
595
|
+
|
|
596
|
+
val existing = queue[currentIndex].toHashMap()
|
|
597
|
+
metadata.toHashMap().forEach { (k, v) -> existing[k] = v }
|
|
598
|
+
val updated = Arguments.makeNativeMap(existing)
|
|
599
|
+
queue[currentIndex] = updated
|
|
600
|
+
|
|
601
|
+
// Update the live MediaItem in the player
|
|
602
|
+
val currentItem = player.currentMediaItem ?: return
|
|
603
|
+
val newMetadata = currentItem.mediaMetadata.buildUpon()
|
|
604
|
+
.also { builder ->
|
|
605
|
+
if (metadata.hasKey("title")) builder.setTitle(metadata.getString("title"))
|
|
606
|
+
if (metadata.hasKey("artist")) builder.setArtist(metadata.getString("artist"))
|
|
607
|
+
if (metadata.hasKey("album")) builder.setAlbumTitle(metadata.getString("album"))
|
|
608
|
+
if (metadata.hasKey("artwork")) builder.setArtworkUri(android.net.Uri.parse(metadata.getString("artwork")))
|
|
609
|
+
}
|
|
610
|
+
.build()
|
|
611
|
+
|
|
612
|
+
player.replaceMediaItem(currentIndex, currentItem.buildUpon().setMediaMetadata(newMetadata).build())
|
|
613
|
+
updateMediaNotification()
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
fun updateOptions(opts: ReadableMap) {
|
|
617
|
+
// Merge new options with existing ones to avoid wiping out buffer settings
|
|
618
|
+
val merged = Arguments.createMap()
|
|
619
|
+
options?.let { merged.merge(it) }
|
|
620
|
+
merged.merge(opts)
|
|
621
|
+
options = merged
|
|
622
|
+
|
|
623
|
+
// Restart progress updates if the interval changed
|
|
624
|
+
if (opts.hasKey("progressUpdateEventInterval")) {
|
|
625
|
+
startProgressUpdates()
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── Progress updates ────────────────────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
private fun startProgressUpdates() {
|
|
632
|
+
stopProgressUpdates()
|
|
633
|
+
val interval = (getDouble(options, "progressUpdateEventInterval", 1.0) * 1000).toLong()
|
|
634
|
+
progressJob = scope.launch {
|
|
635
|
+
while (isActive) {
|
|
636
|
+
delay(interval)
|
|
637
|
+
if (player.isPlaying) {
|
|
638
|
+
val map = Arguments.createMap()
|
|
639
|
+
map.putDouble("position", player.currentPosition / 1000.0)
|
|
640
|
+
map.putDouble("duration", if (player.duration == C.TIME_UNSET) 0.0 else player.duration / 1000.0)
|
|
641
|
+
map.putDouble("buffered", player.bufferedPosition / 1000.0)
|
|
642
|
+
map.putInt("track", player.currentMediaItemIndex)
|
|
643
|
+
eventEmitter?.invoke("playback-progress-updated", map)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private fun stopProgressUpdates() {
|
|
650
|
+
progressJob?.cancel()
|
|
651
|
+
progressJob = null
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── MediaItem builder ───────────────────────────────────────────────────────
|
|
655
|
+
|
|
656
|
+
private fun buildMediaItem(track: ReadableMap): MediaItem {
|
|
657
|
+
val url = track.getString("url") ?: ""
|
|
658
|
+
val title = track.getString("title") ?: ""
|
|
659
|
+
val artist = track.getString("artist") ?: ""
|
|
660
|
+
val album = track.getString("album") ?: ""
|
|
661
|
+
val artworkUri = track.getString("artwork")
|
|
662
|
+
|
|
663
|
+
val metadata = MediaMetadata.Builder()
|
|
664
|
+
.setTitle(title)
|
|
665
|
+
.setArtist(artist)
|
|
666
|
+
.setAlbumTitle(album)
|
|
667
|
+
.also { builder ->
|
|
668
|
+
artworkUri?.takeIf { it.isNotEmpty() }?.let {
|
|
669
|
+
builder.setArtworkUri(android.net.Uri.parse(it))
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
.build()
|
|
673
|
+
|
|
674
|
+
return MediaItem.Builder()
|
|
675
|
+
.setUri(url)
|
|
676
|
+
.setMediaId(track.getString("id") ?: url)
|
|
677
|
+
.setMediaMetadata(metadata)
|
|
678
|
+
.also { builder ->
|
|
679
|
+
// Custom headers
|
|
680
|
+
val headers = track.getMap("headers")
|
|
681
|
+
if (headers != null) {
|
|
682
|
+
val headersMap = headers.toHashMap().mapValues { it.value.toString() }
|
|
683
|
+
if (headersMap.isNotEmpty()) {
|
|
684
|
+
builder.setRequestMetadata(
|
|
685
|
+
MediaItem.RequestMetadata.Builder()
|
|
686
|
+
.setExtras(android.os.Bundle().apply {
|
|
687
|
+
headersMap.forEach { (k, v) -> putString(k, v) }
|
|
688
|
+
})
|
|
689
|
+
.build()
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
.build()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Player listener ─────────────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
private val playerListener = object : Player.Listener {
|
|
700
|
+
|
|
701
|
+
private var lastIndex = -1
|
|
702
|
+
|
|
703
|
+
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
704
|
+
emitPlaybackState()
|
|
705
|
+
updateMediaNotification()
|
|
706
|
+
if (playbackState == Player.STATE_ENDED) {
|
|
707
|
+
// Emit track ended for the last item in the queue if it just finished
|
|
708
|
+
val map = Arguments.createMap()
|
|
709
|
+
map.putInt("index", player.currentMediaItemIndex)
|
|
710
|
+
eventEmitter?.invoke("playback-track-ended", map)
|
|
711
|
+
|
|
712
|
+
// Then emit queue ended
|
|
713
|
+
val queueMap = Arguments.createMap()
|
|
714
|
+
queueMap.putInt("index", player.currentMediaItemIndex)
|
|
715
|
+
queueMap.putDouble("position", player.currentPosition / 1000.0)
|
|
716
|
+
eventEmitter?.invoke("playback-queue-ended", queueMap)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
721
|
+
emitPlaybackState()
|
|
722
|
+
updateMediaNotification()
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
726
|
+
// If we transitioned to a new item, the previous one "ended" its playback
|
|
727
|
+
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) {
|
|
728
|
+
val endedMap = Arguments.createMap()
|
|
729
|
+
endedMap.putInt("index", lastIndex)
|
|
730
|
+
eventEmitter?.invoke("playback-track-ended", endedMap)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
val newIndex = player.currentMediaItemIndex
|
|
734
|
+
val map = Arguments.createMap()
|
|
735
|
+
map.putInt("index", newIndex)
|
|
736
|
+
map.putInt("lastIndex", lastIndex)
|
|
737
|
+
map.putDouble("lastPosition", player.currentPosition / 1000.0)
|
|
738
|
+
|
|
739
|
+
if (newIndex >= 0 && newIndex < queue.size) {
|
|
740
|
+
map.putMap("track", Arguments.makeNativeMap(queue[newIndex].toHashMap()))
|
|
741
|
+
} else {
|
|
742
|
+
map.putNull("track")
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (lastIndex >= 0 && lastIndex < queue.size) {
|
|
746
|
+
map.putMap("lastTrack", Arguments.makeNativeMap(queue[lastIndex].toHashMap()))
|
|
747
|
+
} else {
|
|
748
|
+
map.putNull("lastTrack")
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
eventEmitter?.invoke("playback-active-track-changed", map)
|
|
752
|
+
lastIndex = newIndex
|
|
753
|
+
updateMediaNotification()
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
override fun onPlayerError(error: PlaybackException) {
|
|
757
|
+
val map = Arguments.createMap()
|
|
758
|
+
map.putString("code", "playback_error_${error.errorCode}")
|
|
759
|
+
map.putString("message", error.message ?: "Unknown playback error")
|
|
760
|
+
eventEmitter?.invoke("playback-error", map)
|
|
761
|
+
emitPlaybackState()
|
|
762
|
+
|
|
763
|
+
// Optional auto-skip recovery
|
|
764
|
+
val autoSkip = options?.getMap("android")?.let { androidMap ->
|
|
765
|
+
androidMap.hasKey("autoSkipOnError") && androidMap.getBoolean("autoSkipOnError")
|
|
766
|
+
} ?: false
|
|
767
|
+
if (autoSkip && player.hasNextMediaItem()) {
|
|
768
|
+
player.seekToNextMediaItem()
|
|
769
|
+
player.prepare()
|
|
770
|
+
player.play()
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private fun emitPlaybackState() {
|
|
775
|
+
val map = Arguments.createMap()
|
|
776
|
+
map.putString("state", mapPlayerState())
|
|
777
|
+
eventEmitter?.invoke("playback-state", map)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ── MediaSession callback ───────────────────────────────────────────────────
|
|
782
|
+
|
|
783
|
+
private val mediaSessionCallback = object : MediaLibrarySession.Callback {
|
|
784
|
+
|
|
785
|
+
override fun onAddMediaItems(
|
|
786
|
+
mediaSession: MediaSession,
|
|
787
|
+
controller: MediaSession.ControllerInfo,
|
|
788
|
+
mediaItems: MutableList<MediaItem>
|
|
789
|
+
): com.google.common.util.concurrent.ListenableFuture<MutableList<MediaItem>> {
|
|
790
|
+
// Resolve URIs for Android Auto / external controllers
|
|
791
|
+
val resolved = mediaItems.map { item ->
|
|
792
|
+
item.buildUpon().setUri(item.requestMetadata.mediaUri).build()
|
|
793
|
+
}.toMutableList()
|
|
794
|
+
return com.google.common.util.concurrent.Futures.immediateFuture(resolved)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|