diff --git a/android/app/build.gradle b/android/app/build.gradle index 0c47c826..248f12ae 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 15 - versionName "0.9.0-beta" + versionCode 16 + versionName "0.9.1-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 b7dad733..b7eeaaa2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,9 +6,8 @@ - - - + + - - - - + + + + + + + + + + + + + + @@ -51,8 +57,12 @@ + + + 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 8117e547..baf5fd83 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt @@ -34,9 +34,19 @@ class Audiobook { cover = jsondata.getString("cover", "").toString() playlistUrl = jsondata.getString("playlistUrl", "").toString() playWhenReady = jsondata.getBoolean("playWhenReady", false) == true - startTime = jsondata.getString("startTime", "0")!!.toLong() - playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat() - duration = jsondata.getString("duration", "0")!!.toLong() + + if (jsondata.has("startTime")) { + startTime = jsondata.getString("startTime", "0")!!.toLong() + } + + if (jsondata.has("duration")) { + duration = jsondata.getString("duration", "0")!!.toLong() + } + + if (jsondata.has("playbackSpeed")) { + playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat() + } + // Local data isLocal = jsondata.getBoolean("isLocal", false) == true diff --git a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt index 73855bae..cae74254 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt @@ -60,12 +60,12 @@ class MainActivity : BridgeActivity() { mConnection = object : ServiceConnection { override fun onServiceDisconnected(name: ComponentName) { - Log.w(tag, "Service Disconnected") + Log.w(tag, "Service Disconnected $name") mBounded = false } override fun onServiceConnected(name: ComponentName, service: IBinder) { - Log.d(tag, "Service Connected") + Log.d(tag, "Service Connected $name") mBounded = true diff --git a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt b/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt index fd9fcada..94919d5e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt @@ -5,11 +5,9 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat -import com.getcapacitor.JSObject -import com.getcapacitor.Plugin -import com.getcapacitor.PluginCall -import com.getcapacitor.PluginMethod +import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin +import org.json.JSONObject @CapacitorPlugin(name = "MyNativeAudio") class MyNativeAudio : Plugin() { @@ -31,6 +29,13 @@ class MyNativeAudio : Plugin() { override fun onMetadata(metadata:JSObject) { notifyListeners("onMetadata", metadata) } + override fun onPrepare(audiobookId:String, playWhenReady:Boolean) { + var jsobj = JSObject() + jsobj.put("audiobookId", audiobookId) + jsobj.put("playWhenReady", playWhenReady) + notifyListeners("onPrepareMedia", jsobj) + } + override fun onCar() {} }) } mainActivity.pluginCallback = foregroundServiceReady @@ -141,4 +146,37 @@ class MyNativeAudio : Plugin() { call.resolve() } } + + @PluginMethod + fun setAudiobooks(call: PluginCall) { + var audiobooks = call.getArray("audiobooks", JSArray()) + if (audiobooks == null) { + Log.w(tag, "setAudiobooks IS NULL") + call.resolve() + return + } + + var audiobookObjs = mutableListOf() + + var len = audiobooks.length() + (0 until len).forEach { _it -> + var jsonobj = audiobooks.get(_it) as JSONObject + + var _names = Array(jsonobj.names().length()) { + jsonobj.names().getString(it) + } + var jsobj = JSObject(jsonobj, _names) + + if (jsobj.has("duration")) { + var dur = jsobj.getDouble("duration") + var duration = Math.floor(dur * 1000L).toLong() + jsobj.put("duration", duration) + } + + var audiobook = Audiobook(jsobj) + audiobookObjs.add(audiobook) + } + Log.d(tag, "Setting Audiobooks ${audiobookObjs.size}") + playerNotificationService.setAudiobooks(audiobookObjs) + } } 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 89b78850..4018dbcd 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt @@ -6,17 +6,18 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.Color import android.net.Uri -import android.os.Binder -import android.os.Build -import android.os.IBinder -import android.provider.MediaStore +import android.os.* +import android.support.v4.media.MediaBrowserCompat 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 +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions @@ -25,20 +26,17 @@ import com.google.android.exoplayer2.* import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaExtractor import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.upstream.* import kotlinx.coroutines.* -import java.io.File const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px -class PlayerNotificationService : Service() { +class PlayerNotificationService : MediaBrowserServiceCompat() { companion object { var isStarted = false @@ -47,6 +45,8 @@ class PlayerNotificationService : Service() { interface MyCustomObjectListener { fun onPlayingUpdate(isPlaying: Boolean) fun onMetadata(metadata: JSObject) + fun onPrepare(audiobookId:String, playWhenReady:Boolean) + fun onCar() } private val tag = "PlayerService" @@ -71,6 +71,8 @@ class PlayerNotificationService : Service() { private var currentAudiobook:Audiobook? = null + private var audiobooks = mutableListOf() + fun setCustomObjectListener(mylistener: MyCustomObjectListener) { listener = mylistener } @@ -78,8 +80,14 @@ class PlayerNotificationService : Service() { /* Service related stuff */ - override fun onBind(intent: Intent?): IBinder? { + override fun onBind(intent: Intent): IBinder? { Log.d(tag, "onBind") + + // Android Auto Media Browser Service + if (SERVICE_INTERFACE.equals(intent.getAction())) { + Log.d(tag, "Is Media Browser Service") + return super.onBind(intent); + } return binder } @@ -97,11 +105,13 @@ class PlayerNotificationService : Service() { Log.d(tag, "onStartCommand $startId") isStarted = true - - return START_STICKY } + override fun onStart(intent: Intent?, startId: Int) { + Log.d(tag, "onStart $startId" ) + } + @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel(channelId: String, channelName: String): String{ val chan = NotificationChannel(channelId, @@ -166,6 +176,9 @@ class PlayerNotificationService : Service() { val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) + // This is for Media Browser + sessionToken = mediaSession.sessionToken + val builder = PlayerNotificationManager.Builder( ctx, notificationId, @@ -232,7 +245,53 @@ class PlayerNotificationService : Service() { return builder.build() } } + + 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 { + Log.d(tag, "GET SUPORTED ACITONS") + 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 = 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 = 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") + } + + } mediaSessionConnector.setQueueNavigator(queueNavigator) + mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer) mediaSessionConnector.setPlayer(mPlayer) //attach player to playerNotificationManager @@ -286,9 +345,6 @@ class PlayerNotificationService : Service() { } } - - - private fun setPlayerListeners() { mPlayer.addListener(object : Player.Listener { override fun onPlayerError(error: PlaybackException) { @@ -451,4 +507,81 @@ class PlayerNotificationService : Service() { metadata.put("stateName", stateName) if (listener != null) listener.onMetadata(metadata) } + + + // + // MEDIA BROWSER STUFF (ANDROID AUTO) + // + private val MY_MEDIA_ROOT_ID = "audiobookshelf" + + fun setAudiobooks(_audiobooks:MutableList) { + audiobooks = _audiobooks + } + + private fun isValid(packageName:String, uid:Int) : Boolean { + Log.d(tag, "Check package $packageName is valid with uid $uid") + return true + } + + override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { + // Verify that the specified package is allowed to access your + // content! You'll need to write your own logic to do this. + return if (!isValid(clientPackageName, clientUid)) { + // If the request comes from an untrusted package, return null. + // No further calls will be made to other media browsing methods. + null + } else { + listener.onCar() + + val extras = Bundle() + 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) + } + } + + override fun onLoadChildren(parentMediaId: String, result: Result>) { + val mediaItems: MutableList = mutableListOf() + + if (audiobooks.size == 0) { + result.sendResult(mediaItems) + return + } + + audiobooks.forEach { + var builder = MediaDescriptionCompat.Builder() + .setMediaId(it.id) + .setTitle(it.title) + .setSubtitle(it.author) + .setMediaUri(it.playlistUri) + .setIconUri(it.coverUri) + +// val extras = Bundle() +// var startsWithA = it.title.toLowerCase().startsWith("a") +// var groupTitle = "test group +// extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) +// builder.setExtras(extras)\ +// Log.d(tag, "Load Media Item for AUTO ${it.title} - ${it.author}") + + 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) { + // build the MediaItem objects for the top level, + // and put them in the mediaItems list + } else { + + // examine the passed parentMediaId to see which submenu we're at, + // and put the children of that menu in the mediaItems list + } + result.sendResult(mediaItems) + } } + diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..b4a189f6 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,3 @@ + + + diff --git a/layouts/default.vue b/layouts/default.vue index 2b3ec4e2..0d184585 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -10,11 +10,11 @@