From bf8e48fd275d003a48e56d3caa7e3fbfa3210221 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 11 Nov 2021 20:38:51 -0600 Subject: [PATCH] Add: Android auto first attempt --- android/app/build.gradle | 4 +- android/app/src/main/AndroidManifest.xml | 13 - .../java/com/audiobookshelf/app/Audiobook.kt | 93 ++++- .../audiobookshelf/app/AudiobookManager.kt | 235 +++++++++-- .../java/com/audiobookshelf/app/BrowseTree.kt | 71 ++++ .../app/PlayerNotificationService.kt | 373 +++++++++++------- .../drawable-anydpi-v24/exo_icon_books.xml | 18 + .../exo_icon_downloaddone.xml | 18 + .../main/res/drawable-hdpi/exo_icon_books.png | Bin 0 -> 276 bytes .../drawable-hdpi/exo_icon_downloaddone.png | Bin 0 -> 215 bytes .../main/res/drawable-mdpi/exo_icon_books.png | Bin 0 -> 199 bytes .../drawable-mdpi/exo_icon_downloaddone.png | Bin 0 -> 176 bytes .../res/drawable-xhdpi/exo_icon_books.png | Bin 0 -> 309 bytes .../drawable-xhdpi/exo_icon_downloaddone.png | Bin 0 -> 355 bytes .../res/drawable-xxhdpi/exo_icon_books.png | Bin 0 -> 430 bytes .../drawable-xxhdpi/exo_icon_downloaddone.png | Bin 0 -> 448 bytes package.json | 2 +- 17 files changed, 634 insertions(+), 193 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt create mode 100644 android/app/src/main/res/drawable-anydpi-v24/exo_icon_books.xml create mode 100644 android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml create mode 100644 android/app/src/main/res/drawable-hdpi/exo_icon_books.png create mode 100644 android/app/src/main/res/drawable-hdpi/exo_icon_downloaddone.png create mode 100644 android/app/src/main/res/drawable-mdpi/exo_icon_books.png create mode 100644 android/app/src/main/res/drawable-mdpi/exo_icon_downloaddone.png create mode 100644 android/app/src/main/res/drawable-xhdpi/exo_icon_books.png create mode 100644 android/app/src/main/res/drawable-xhdpi/exo_icon_downloaddone.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/exo_icon_books.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/exo_icon_downloaddone.png diff --git a/android/app/build.gradle b/android/app/build.gradle index 7becd87d..d7252823 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.audiobookshelf.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 37 - versionName "0.9.18-beta" + versionCode 38 + versionName "0.9.19-beta" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f67b82b5..a5c482ba 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,23 +36,10 @@ - - - - - - - - - - - - - diff --git a/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt b/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt index 0c9b75cf..0832b189 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt @@ -1,6 +1,10 @@ package com.audiobookshelf.app import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat import com.getcapacitor.JSObject class Audiobook { @@ -16,10 +20,21 @@ class Audiobook { var isInvalid:Boolean var path:String - var fallbackCover:Uri - var fallbackUri:Uri + var isDownloaded:Boolean = false + var downloadFolderUrl:String = "" + var folderUrl:String = "" + var contentUrl:String = "" + var filename:String = "" + var localCoverUrl:String = "" + var localCover:String = "" + + var serverUrl:String = "" + var token:String = "" + + constructor(jsobj: JSObject, serverUrl:String, token:String) { + this.serverUrl = serverUrl + this.token = token - constructor(jsobj: JSObject) { id = jsobj.getString("id", "").toString() ino = jsobj.getString("ino", "").toString() libraryId = jsobj.getString("libraryId", "").toString() @@ -35,11 +50,75 @@ class Audiobook { isInvalid = jsobj.getBoolean("isInvalid") path = jsobj.getString("path", "").toString() - fallbackUri = Uri.parse("http://fallback.com/run.mp3") - fallbackCover = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + isDownloaded = jsobj.getBoolean("isDownloaded") + if (isDownloaded) { + downloadFolderUrl = jsobj.getString("downloadFolderUrl", "").toString() + folderUrl = jsobj.getString("folderUrl", "").toString() + contentUrl = jsobj.getString("contentUrl", "").toString() + filename = jsobj.getString("filename", "").toString() + localCover = jsobj.getString("localCover", "").toString() + localCoverUrl = jsobj.getString("localCoverUrl", "").toString() + } } - fun getCover(serverUrl:String, token:String):Uri { - return Uri.parse("$serverUrl/${book.cover}?token=$token") + fun getCover():Uri { + if (isDownloaded) { +// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + return Uri.parse(localCoverUrl) + } + if (book.cover == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}") + } + + fun getDurationLong():Long { + return duration.toLong() * 1000L + } + + fun toMediaItem():MediaBrowserCompat.MediaItem { + var builder = MediaDescriptionCompat.Builder() + .setMediaId(id) + .setTitle(book.title) + .setSubtitle(book.authorFL) + .setMediaUri(null) + .setIconUri(getCover()) + + val extras = Bundle() + if (isDownloaded) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED) + } +// extras.putInt( +// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, +// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED) + builder.setExtras(extras) + + var mediaDescription = builder.build() + return MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + + fun toMediaMetadata():MediaMetadataCompat { + return MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, book.title) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, book.title) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, book.authorFL) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCover().toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCover().toString()) + putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCover().toString()) + putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, book.authorFL) +// val extras = Bundle() +// if (isDownloaded) { +// extras.putLong( +// MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, +// MediaDescriptionCompat.STATUS_DOWNLOADED) +// } +// extras.putInt( +// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, +// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED) + +// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, RESOURCE_ROOT_URI + +// context.resources.getResourceEntryName(R.drawable.notification_bg_low_normal)) + }.build() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt b/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt index 9793d134..ca0e8c56 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt @@ -2,6 +2,7 @@ package com.audiobookshelf.app import android.app.Activity import android.content.Context +import android.os.Bundle import android.os.Handler import android.os.Looper import android.support.v4.media.MediaBrowserCompat @@ -9,17 +10,22 @@ import android.support.v4.media.MediaDescriptionCompat import android.util.Log import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants import com.getcapacitor.JSArray import com.getcapacitor.JSObject import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite import okhttp3.* import java.io.IOException -import java.net.URL class AudiobookManager { var tag = "AudiobookManager" + interface OnStreamData { + fun onStreamReady(asd:AudiobookStreamData) + } + var hasLoaded = false + var isLoading = false var ctx: Context var serverUrl = "" var token = "" @@ -40,9 +46,157 @@ class AudiobookManager { Log.d(tag, "SHARED PREF TOKEN $token") } + fun loadAudiobooks(cb: (() -> Unit)) { + if (serverUrl == "" || token == "") { + Log.d(tag, "No Server or Token set") + cb() + return + } + + var url = "$serverUrl/api/library/main/audiobooks" + val request = Request.Builder() + .url(url).addHeader("Authorization", "Bearer $token") + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + cb() + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) throw IOException("Unexpected code $response") + + var bodyString = response.body!!.string() + var json = JSArray(bodyString) + var totalBooks = json.length() - 1 + for (i in 0..totalBooks) { + var abobj = json.get(i) + var jsobj = JSObject(abobj.toString()) + jsobj.put("isDownloaded", false) + var audiobook = Audiobook(jsobj, serverUrl, token) + + if (audiobook.isMissing || audiobook.isInvalid) { + Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid") + } else if (audiobook.numTracks <= 0) { + Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks") + } else { + var audiobookExists = audiobooks.find { it.id == audiobook.id } + if (audiobookExists == null) { + audiobooks.add(audiobook) + } else { + Log.d(tag, "Audiobook already there from downloaded") + } + } + } + Log.d(tag, "${audiobooks.size} Audiobooks Loaded") + cb() + } + } + }) + } + fun fetchAudiobooks(result: MediaBrowserServiceCompat.Result>) { var url = "$serverUrl/api/library/main/audiobooks" - Log.d(tag, "RUNNING SAMPLER $url") + val request = Request.Builder() + .url(url).addHeader("Authorization", "Bearer $token") + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) throw IOException("Unexpected code $response") + + var bodyString = response.body!!.string() + var json = JSArray(bodyString) + var totalBooks = json.length() - 1 + for (i in 0..totalBooks) { + var abobj = json.get(i) + var jsobj = JSObject(abobj.toString()) + jsobj.put("isDownloaded", false) + var audiobook = Audiobook(jsobj, serverUrl, token) + + if (audiobook.isMissing || audiobook.isInvalid) { + Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid") + } else if (audiobook.numTracks <= 0) { + Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks") + } else { + var audiobookExists = audiobooks.find { it.id == audiobook.id } + if (audiobookExists == null) { + audiobooks.add(audiobook) + } else { + Log.d(tag, "Audiobook already there from downloaded") + } + } + } + Log.d(tag, "${audiobooks.size} Audiobooks Loaded") + + val mediaItems: MutableList = mutableListOf() + audiobooks.forEach { + var builder = MediaDescriptionCompat.Builder() + .setMediaId(it.id) + .setTitle(it.book.title) + .setSubtitle(it.book.authorFL) + .setMediaUri(null) + .setIconUri(it.getCover()) + + val extras = Bundle() + if (it.isDownloaded) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED) + } +// extras.putInt( +// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, +// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED) + builder.setExtras(extras) + + var mediaDescription = builder.build() + var newMediaItem = MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + mediaItems.add(newMediaItem) + + } + Log.d(tag, "AudiobookManager: Sending ${mediaItems.size} Audiobooks") + result.sendResult(mediaItems) + } + } + }) + } + + fun load() { + isLoading = true + hasLoaded = true + + var db = CapacitorDataStorageSqlite(ctx) + db.openStore("storage", "downloads", false, "no-encryption", 1) + var keyvalues = db.keysvalues() + keyvalues.forEach { + Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}") + + var dlobj = JSObject(it.getString("value")) + var abobj = dlobj.getJSObject("audiobook")!! + abobj.put("isDownloaded", true) + abobj.put("contentUrl", dlobj.getString("contentUrl", "").toString()) + abobj.put("filename", dlobj.getString("filename", "").toString()) + abobj.put("folderUrl", dlobj.getString("folderUrl", "").toString()) + abobj.put("downloadFolderUrl", dlobj.getString("downloadFolderUrl", "").toString()) + abobj.put("localCoverUrl", dlobj.getString("coverUrl", "").toString()) + abobj.put("localCover", dlobj.getString("cover", "").toString()) + + var audiobook = Audiobook(abobj, serverUrl, token) + audiobooks.add(audiobook) + } + } + + fun openStream(audiobook:Audiobook, streamListener:OnStreamData) { + var url = "$serverUrl/api/audiobook/${audiobook.id}/stream" val request = Request.Builder() .url(url).addHeader("Authorization", "Bearer $token") .build() @@ -55,51 +209,56 @@ class AudiobookManager { override fun onResponse(call: Call, response: Response) { response.use { if (!response.isSuccessful) throw IOException("Unexpected code $response") -// for ((name, value) in response.headers) { -// Log.d(tag, "HEADER $name: $value") -// } var bodyString = response.body!!.string() - var json = JSArray(bodyString) - var totalBooks = json.length() - 1 - for (i in 0..totalBooks) { - var abobj = json.get(i) - var jsobj = JSObject(abobj.toString()) - var audiobook = Audiobook(jsobj) - audiobooks.add(audiobook) - Log.d(tag, "Audiobook: ${audiobook.toString()}") + var stream = JSObject(bodyString) + var startTime = stream.getDouble("startTime") + var streamUrl = stream.getString("streamUrl", "").toString() + + var startTimeLong = (startTime * 1000).toLong() + + var abStreamDataObj = JSObject() + abStreamDataObj.put("id", audiobook.id) + abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl") + abStreamDataObj.put("title", audiobook.book.title) + abStreamDataObj.put("author", audiobook.book.authorFL) + abStreamDataObj.put("token", token) + abStreamDataObj.put("cover", audiobook.getCover()) + abStreamDataObj.put("duration", audiobook.getDurationLong()) + abStreamDataObj.put("startTime", startTimeLong) + abStreamDataObj.put("playbackSpeed", 1) + abStreamDataObj.put("playWhenReady", true) + abStreamDataObj.put("isLocal", false) + + var audiobookStreamData = AudiobookStreamData(abStreamDataObj) + + Handler(Looper.getMainLooper()).post() { + Log.d(tag, "Stream Ready on Main Looper") + streamListener.onStreamReady(audiobookStreamData) } - Log.d(tag, "Audiobooks Loaded") - val mediaItems: MutableList = mutableListOf() - audiobooks.forEach { - var builder = MediaDescriptionCompat.Builder() - .setMediaId(it.id) - .setTitle(it.book.title) - .setSubtitle(it.book.authorFL) - .setMediaUri(it.fallbackUri) - .setIconUri(it.fallbackCover) - - var mediaDescription = builder.build() - var newMediaItem = MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - mediaItems.add(newMediaItem) - - } - Log.d(tag, "AudiobookManager: Sending ${mediaItems.size} Aduiobooks") - result.sendResult(mediaItems) + Log.d(tag, "Init Player Stream") } } }) } - fun load() { - hasLoaded = true + fun initLocalPlay(audiobook:Audiobook):AudiobookStreamData { - var db = CapacitorDataStorageSqlite(ctx) - db.openStore("storage", "downloads", false, "no-encryption", 1) - Log.d(tag, "CHECK IF DB IS OPEN ${db.isStoreOpen("storage")}") - var keyvalues = db.keysvalues() - Log.d(tag, "KEY VALUES $keyvalues") - keyvalues.forEach { Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}") } + var abStreamDataObj = JSObject() + abStreamDataObj.put("id", audiobook.id) + abStreamDataObj.put("contentUrl", audiobook.contentUrl) + abStreamDataObj.put("title", audiobook.book.title) + abStreamDataObj.put("author", audiobook.book.authorFL) + abStreamDataObj.put("token", null) + abStreamDataObj.put("cover", audiobook.getCover()) + abStreamDataObj.put("duration", audiobook.getDurationLong()) + abStreamDataObj.put("startTime", 0) + abStreamDataObj.put("playbackSpeed", 1) + abStreamDataObj.put("playWhenReady", true) + abStreamDataObj.put("isLocal", true) + + var audiobookStreamData = AudiobookStreamData(abStreamDataObj) + return audiobookStreamData } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt new file mode 100644 index 00000000..ebec88fc --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt @@ -0,0 +1,71 @@ +package com.audiobookshelf.app + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import android.util.Log +import androidx.annotation.AnyRes + + +class BrowseTree( + val context: Context, + val audiobooks: List, + val recentMediaId: String? = null +) { + private val mediaIdToChildren = mutableMapOf>() + + /** + * get uri to drawable or any other resource type if u wish + * @param context - context + * @param drawableId - drawable res id + * @return - uri + */ + fun getUriToDrawable(context: Context, + @AnyRes drawableId: Int): Uri { + return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId)) + } + + init { + val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() + + val allMetadata = MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Audiobooks") + + var resource = getUriToDrawable(context, R.drawable.exo_icon_books).toString() + Log.d("BrowseTree", "RESOURCE $resource") + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, resource) + }.build() + + val albumsMetadata = MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) + }.build() + + rootList += allMetadata + rootList += albumsMetadata + mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList + + audiobooks.forEach { audiobook -> + if (audiobook.isDownloaded) { + val downloadsChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf() + downloadsChildren += audiobook.toMediaMetadata() + mediaIdToChildren[DOWNLOADS_ROOT] = downloadsChildren + } + val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf() + allChildren += audiobook.toMediaMetadata() + mediaIdToChildren[ALL_ROOT] = allChildren + } + } + + operator fun get(mediaId: String) = mediaIdToChildren[mediaId] +} + +const val AUTO_BROWSE_ROOT = "/" +const val ALL_ROOT = "__ALL__" +const val DOWNLOADS_ROOT = "__DOWNLOADS__" diff --git a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt index e6589223..15fb4024 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt @@ -12,7 +12,6 @@ import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat @@ -54,9 +53,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun onSleepTimerEnded(currentPosition:Long) } + private val tag = "PlayerService" - private lateinit var listener:MyCustomObjectListener + private var listener:MyCustomObjectListener? = null + private lateinit var ctx:Context private lateinit var mPlayer: SimpleExoPlayer private lateinit var mediaSessionConnector: MediaSessionConnector @@ -266,51 +267,64 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } - val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer { - override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { - Log.d(tag, "ON COMMAND $command") - return false - } - - override fun getSupportedPrepareActions(): Long { - return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or - PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH - } - - override fun onPrepare(playWhenReady: Boolean) { - Log.d(tag, "ON PREPARE $playWhenReady") - - var audiobook = audiobookManager.audiobooks[0] - if (audiobook == null) { - Log.e(tag, "Audiobook NOT FOUND") - return - } - listener.onPrepare(audiobook.id, playWhenReady) - } - - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") - var audiobook = audiobookManager.audiobooks.find { it.id == mediaId } - if (audiobook == null) { - Log.e(tag, "Audiobook NOT FOUND") - return - } - listener.onPrepare(audiobook.id, playWhenReady) - } - - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM SEARCH $query") - } - - override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM URI $uri") - } - - } +// val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer { +// override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { +// Log.d(tag, "ON COMMAND $command") +// return false +// } +// +// override fun getSupportedPrepareActions(): Long { +// return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or +// PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or +// PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or +// PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH +// } +// +// override fun onPrepare(playWhenReady: Boolean) { +// Log.d(tag, "ON PREPARE $playWhenReady") +// var audiobook = audiobookManager.audiobooks[0] +// if (audiobook == null) { +// Log.e(tag, "Audiobook NOT FOUND") +// return +// } +// +// var streamListener = object : AudiobookManager.OnStreamData { +// override fun onStreamReady(asd: AudiobookStreamData) { +// Log.d(tag, "Stream Ready ${asd.playlistUrl}") +// initPlayer(asd) +// } +// } +// audiobookManager.openStream(audiobook, streamListener) +// } +// +// override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { +// Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") +// var audiobook = audiobookManager.audiobooks.find { it.id == mediaId } +// if (audiobook == null) { +// Log.e(tag, "Audiobook NOT FOUND") +// return +// } +// +// var streamListener = object : AudiobookManager.OnStreamData { +// override fun onStreamReady(asd: AudiobookStreamData) { +// Log.d(tag, "Stream Ready ${asd.playlistUrl}") +// initPlayer(asd) +// } +// } +// audiobookManager.openStream(audiobook, streamListener) +// } +// +// override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { +// Log.d(tag, "ON PREPARE FROM SEARCH $query") +// } +// +// override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { +// Log.d(tag, "ON PREPARE FROM URI $uri") +// } +// +// } mediaSessionConnector.setQueueNavigator(queueNavigator) - mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer) +// mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer) mediaSessionConnector.setPlayer(mPlayer) //attach player to playerNotificationManager @@ -318,12 +332,124 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS) mediaSession.setCallback(object : MediaSessionCompat.Callback() { + override fun onPrepare() { + Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") + super.onPrepare() + } + override fun onPlay() { + Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") + play() + } + override fun onPause() { + Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") + pause() + } + override fun onStop() { + pause() + } + override fun onSkipToPrevious() { + seekBackward(seekAmount) + } + override fun onSkipToNext() { + seekForward(seekAmount) + } + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") + + var audiobook = audiobookManager.audiobooks.find { it.id == mediaId } + if (audiobook == null) { + Log.e(tag, "Audiobook NOT FOUND") + return + } + + if (!audiobook.isDownloaded) { + + var streamListener = object : AudiobookManager.OnStreamData { + override fun onStreamReady(asd: AudiobookStreamData) { + Log.d(tag, "Stream Ready ${asd.playlistUrl}") + initPlayer(asd) + } + } + audiobookManager.openStream(audiobook, streamListener) + } else { + var asd = audiobookManager.initLocalPlay(audiobook) + initPlayer(asd) + } + } + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { return handleCallMediaButton(mediaButtonEvent) } }) } + + fun handleCallMediaButton(intent: Intent): Boolean { + if(Intent.ACTION_MEDIA_BUTTON == intent.getAction()) { + var keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + if (keyEvent?.getAction() == KeyEvent.ACTION_UP) { + when (keyEvent?.getKeyCode()) { + KeyEvent.KEYCODE_HEADSETHOOK -> { + if(0 == mediaButtonClickCount) { + if (mPlayer.isPlaying) + pause() + else + play() + } + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PLAY -> { + if(0 == mediaButtonClickCount) play() + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + if(0 == mediaButtonClickCount) pause() + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_NEXT -> { + seekForward(seekAmount) + } + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { + seekBackward(seekAmount) + } + KeyEvent.KEYCODE_MEDIA_STOP -> { + terminateStream() + } + else -> { + Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}") + return false + } + } + } + } + return true + } + + fun handleMediaButtonClickCount() { + mediaButtonClickCount++ + if (1 == mediaButtonClickCount) { + Timer().schedule(mediaButtonClickTimeout) { + mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount) + mediaButtonClickCount = 0 + } + } + } + + private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak") + object : Handler(){ + override fun handleMessage(msg: Message) { + super.handleMessage(msg) + if (2 == msg.what) { + seekBackward(seekAmount) + play() + } + else if (msg.what >= 3) { + seekForward(seekAmount) + play() + } + } + } + private inner class DescriptionAdapter(private val controller: MediaControllerCompat) : PlayerNotificationManager.MediaDescriptionAdapter { @@ -441,7 +567,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } else lastPauseTime = System.currentTimeMillis() - if (listener != null) listener.onPlayingUpdate(player.isPlaying) + listener?.onPlayingUpdate(player.isPlaying) } } }) @@ -452,7 +578,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { User callable methods */ -// fun initPlayer(token: String, playlistUri: String, playWhenReady: Boolean, currentTime: Long, title: String, artist: String, albumArt: String) { fun initPlayer(audiobookStreamData: AudiobookStreamData) { currentAudiobookStreamData = audiobookStreamData @@ -585,14 +710,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { metadata.put("duration", duration) metadata.put("currentTime", mPlayer.currentPosition) metadata.put("stateName", stateName) - if (listener != null) listener.onMetadata(metadata) + listener?.onMetadata(metadata) } // // MEDIA BROWSER STUFF (ANDROID AUTO) // - private val MY_MEDIA_ROOT_ID = "audiobookshelf" + private val AUTO_MEDIA_ROOT = "/" + private lateinit var browseTree:BrowseTree private fun isValid(packageName:String, uid:Int) : Boolean { @@ -608,26 +734,58 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // No further calls will be made to other media browsing methods. null } else { + + val maximumRootChildLimit = rootHints?.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, + /* defaultValue= */ 4) +// val supportedRootChildFlags = rootHints.getInt( +// MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS, +// /* defaultValue= */ android.media.browse.MediaBrowser.MediaItem.FLAG_BROWSABLE) + + + val extras = Bundle() + extras.putBoolean( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) extras.putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) extras.putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, extras) + + BrowserRoot(AUTO_MEDIA_ROOT, extras) } } override fun onLoadChildren(parentMediaId: String, result: Result>) { val mediaItems: MutableList = mutableListOf() + Log.d(tag, "ON LOAD CHILDREN $parentMediaId") if (!audiobookManager.hasLoaded) { Log.d(tag, "audiobook manager loading") result.detach() audiobookManager.load() - audiobookManager.fetchAudiobooks(result) + audiobookManager.loadAudiobooks() { + audiobookManager.isLoading = false + + Log.d(tag, "LOADED AUDIOBOOKS") + browseTree = BrowseTree(this, audiobookManager.audiobooks, null) + val children = browseTree[parentMediaId]?.map { item -> + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + if (children != null) { + Log.d(tag, "BROWSE TREE CHILDREN ${children.size}") + } + result.sendResult(children as MutableList?) + } return + } else if (audiobookManager.isLoading) { + Log.d(tag, "AUDIOBOOKS LOADING") + result.detach() + return + } else { + Log.d(tag, "ABs are loaded") } if (audiobookManager.audiobooks.size == 0) { @@ -636,21 +794,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { return } - audiobookManager.audiobooks.forEach { - var builder = MediaDescriptionCompat.Builder() - .setMediaId(it.id) - .setTitle(it.book.title) - .setSubtitle(it.book.authorFL) - .setMediaUri(it.fallbackUri) - .setIconUri(it.getCover(audiobookManager.serverUrl, audiobookManager.token)) - var mediaDescription = builder.build() - var newMediaItem = MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - mediaItems.add(newMediaItem) + var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + + val children = browseTree[parentMediaId]?.map { item -> + MediaBrowserCompat.MediaItem(item.description, flag) } + if (children != null) { + Log.d(tag, "BROWSE TREE $parentMediaId CHILDREN ${children.size}") + } + result.sendResult(children as MutableList?) + +// audiobookManager.audiobooks.forEach { +// var builder = MediaDescriptionCompat.Builder() +// .setMediaId(it.id) +// .setTitle(it.book.title) +// .setSubtitle(it.book.authorFL) +// .setMediaUri(null) +// .setIconUri(it.getCover(audiobookManager.serverUrl, audiobookManager.token)) +// +// +// +// var mediaDescription = builder.build() +// var newMediaItem = MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) +// mediaItems.add(newMediaItem) +// } // Check if this is the root menu: - if (MY_MEDIA_ROOT_ID == parentMediaId) { + if (AUTO_MEDIA_ROOT == parentMediaId) { // build the MediaItem objects for the top level, // and put them in the mediaItems list } else { @@ -658,75 +829,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // examine the passed parentMediaId to see which submenu we're at, // and put the children of that menu in the mediaItems list } - Log.d(tag, "AudiobookManager: Sending ${mediaItems.size} Aduiobooks") - result.sendResult(mediaItems) +// Log.d(tag, "AudiobookManager: Sending ${mediaItems.size} Aduiobooks") +// result.sendResult(mediaItems) } - fun handleCallMediaButton(intent: Intent): Boolean { - if(Intent.ACTION_MEDIA_BUTTON == intent.getAction()) { - var keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) - if (keyEvent?.getAction() == KeyEvent.ACTION_UP) { - when (keyEvent?.getKeyCode()) { - KeyEvent.KEYCODE_HEADSETHOOK -> { - if(0 == mediaButtonClickCount) { - if (mPlayer.isPlaying) - pause() - else - play() - } - handleMediaButtonClickCount() - } - KeyEvent.KEYCODE_MEDIA_PLAY -> { - if(0 == mediaButtonClickCount) play() - handleMediaButtonClickCount() - } - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - if(0 == mediaButtonClickCount) pause() - handleMediaButtonClickCount() - } - KeyEvent.KEYCODE_MEDIA_NEXT -> { - seekForward(seekAmount) - } - KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { - seekBackward(seekAmount) - } - KeyEvent.KEYCODE_MEDIA_STOP -> { - terminateStream() - } - else -> { - Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}") - return false - } - } - } - } - return true - } - - fun handleMediaButtonClickCount() { - mediaButtonClickCount++ - if (1 == mediaButtonClickCount) { - Timer().schedule(mediaButtonClickTimeout) { - handler.sendEmptyMessage(mediaButtonClickCount) - mediaButtonClickCount = 0 - } - } - } - - private val handler : Handler = @SuppressLint("HandlerLeak") - object : Handler(){ - override fun handleMessage(msg: Message) { - super.handleMessage(msg) - if (2 == msg.what) { - seekBackward(seekAmount) - play() - } - else if (msg.what >= 3) { - seekForward(seekAmount) - play() - } - } - } + // + // SLEEP TIMER STUFF + // fun setSleepTimer(time:Long, isChapterTime:Boolean) : Boolean { Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime") @@ -749,7 +858,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Sleep Timer Pausing Player on Chapter") mPlayer.pause() - if (listener != null) listener.onSleepTimerEnded(mPlayer.currentPosition) + listener?.onSleepTimerEnded(mPlayer.currentPosition) sleepTimerTask?.cancel() } } @@ -762,7 +871,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Sleep Timer Pausing Player") mPlayer.pause() } - if (listener != null) listener.onSleepTimerEnded(mPlayer.currentPosition) + listener?.onSleepTimerEnded(mPlayer.currentPosition) } } } diff --git a/android/app/src/main/res/drawable-anydpi-v24/exo_icon_books.xml b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_books.xml new file mode 100644 index 00000000..0072c6e5 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_books.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml new file mode 100644 index 00000000..0c3acf89 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_books.png b/android/app/src/main/res/drawable-hdpi/exo_icon_books.png new file mode 100644 index 0000000000000000000000000000000000000000..5e15a4a9a83a8b888c9ab344e59390240d5f237d GIT binary patch literal 276 zcmV+v0qg#WP)eNk|Nj9%y#GJ-!FwQH55xwfIHuq~eSiV64*EbR2W=q6F$NS`(m((oK#o3Q9OOWW zYltwvffV%v=OC!X_E4-a7#xHW@1qVHb22Z72rAdVjt4uV?z9x8kuWbS}C z2&(@+4!QY2Jb~U0A}@+Z9W?5oQ3nl_gRsca5*#4bK@;fYpa5bWBtTN*I?Q5)zm{Nlhc almGx7a&>?}|BRdf0000|k1|%Oc%$NbB=6Sj}hE&{od;K79g8~n0fLnnG z5QQahJ}=-hd-yQ{O1;BDZx_-7(R!jzVo4|^uJFqVZdZBAU9 zByyRjxt-lpqB}PG*d(FgJ=z}c&M-Dq%e-dQb2Xj0Z2gL5i~qJI-88lod41{hf(r8w z%w1nC3z7^tMeLt7O=`U8@z0v|jnkC?<}QcnH&{<3t1qgO<>C!3n<}^cdAQS(Dxgal NJYD@<);T3K0RWK)QY-)f literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/exo_icon_books.png b/android/app/src/main/res/drawable-mdpi/exo_icon_books.png new file mode 100644 index 0000000000000000000000000000000000000000..3dbd21990d564ad467483ac7bdf289795d545b16 GIT binary patch literal 199 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjeV#6kAr-fh6Am!``~P2`;mEg? zBxbf6h9wLg_x~^FIdeSyVB7yIjBGn}EF6|F@>+a3+`#-+x1sHOoxrlGtb7LdR zXIdfob-^9~Ty8Ojk5wxFY8ChvEMsjsu!6PuMiZNs#6QM4IU6GzQcf}&ePpYMZ8*)e yim@sp!C@(fZNvFVtR`C-PO9^XB&-p&VfdQhqPSy1=1ri>7(8A5T-G@yGywoShDoge literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-mdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000000000000000000000000000000000000..b1242a001deb6028928b531aa02248b9d6e85ec9 GIT binary patch literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj<(@8%Ar-fh6C_x_F#Z4kU!395 zUxyqA?ZfB)w>PkFG5NzBT5Djz%dSx zA^y4%Iy?QpF6lTri9bZ2ZGz5Ehsj24Cxx07L*L9|+Q>We#uN!|x&P|&HgXl*Zj4q8 Z41J-`6aMYk;}3KKgQu&X%Q~loCIASPJ{VeGQm zP5|jLUYf<0mr6v&(PqtPGd0?rbPgRfVcQ!{G7T zJZlGoD$jz527~jQp7(s$y`RYEo7b|qu02g}!S*>m7Wr!poR9KJD{Oqt!N9PRF{$jP wQ}hb6XL=<|nX?=@c0Z}Spq(GE-zhv=vfL#{dEc$hcz=-Tp00i_>zopr0L2e-Z2$lO literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xhdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-xhdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000000000000000000000000000000000000..a89dc73f89d577550b91cd917677dade0de808a9 GIT binary patch literal 355 zcmV-p0i6DcP)0|R7Z6ksU5a=B7bhpDPCAG}&*A7rIw(Den;t;~!9hXMO+-W`e!&W*B-m=( zX8#*hp~`5xrXp17FhUpSb48xaRc9^=Y$_7GEd`8Ia> zn?QbxUA{u{Bp*$F9lJcg#vvm}Ck6RZ607BU6YxnbZ1PTEqU)z6RLB>->sy2sajdRn zoDHO$B7IONdxPayq_e!j#ZcK4b~K<=>RRAulO0M3wVqri-*V@d_6fa(HXiL8py5lRq$zymrg$ep+D)$RZQ002ovPDHLkV1n>g Blnwv@ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/exo_icon_books.png b/android/app/src/main/res/drawable-xxhdpi/exo_icon_books.png new file mode 100644 index 0000000000000000000000000000000000000000..213a80e87f1707c0b5684cd07704bc853ccbac89 GIT binary patch literal 430 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2U@Y}?aSW-r_4d}r+(Q8(t%+_6 zmKT~Wa9FAFVv!1OE!T|(9f5-!ISFnjm;^(%I3_VHU*5p0cl}@&M;Gs=b$ z;8r*P-eHN|7ZskQDJe}XTQisaN`&5W2Zk%g7Ih5_OdJXgF$^!e=jI$({+6laM#bFCANdp@;$TS_v0ydR z%W7rIJ8p8>vl;%{&AV*9w{u&3_JWdWCw{VPJiE1`%)$Ni%S(TBtlv8bYyN+5YGl$Rgmdop(z~)x)1TtRmK|Ra=;<7+=_2;#!cj z!1^uQf}|zWz8x045FDDmmHqAPZ?=EAjUL?lEZrKTUtM+W-Rr9yl2yQXVDNPHb6Mw< G&;$S(Uaooo literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-xxhdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000000000000000000000000000000000000..5499dfae8e226cbed99cb0608cdb121db1350a94 GIT binary patch literal 448 zcmV;x0YCnUP)h*|` z29sP*B`G)e{Qq#amY(}-XhWe%LPSJFL}WGR+?dR{OdE|n&Zx|#`|d+#1(hDocm~IT zl*hSt#o0!Z!%xUuIe+gmOI$hSUC4}p0}eRgfCCOVq2Ww|v>ZndQpKFT0&t=nJU@PH)-Wg2f)|LTVb$foH+Wk(!3nRxJ7? zGjHw#x#Y8_tY?`yv-df5Bgylol{hEC-UQZ#l;YegafU*_qtzlUn8V2&YEyBbm%osr z#Hk7?YMg%{Et-$rMfoX-je7SD96rJH=xUWBk2l!&Or}S56H`@#0i~6hF={ zq;4NL9Wyu?q;(}yC$X9W$u5op2^sX0R#|009ghiA|fIp8+`&;1ks8B@*mv*0000