Initial android auto support, fix bug saving user settings

This commit is contained in:
advplyr 2021-09-21 07:11:59 -05:00
parent e124d3858f
commit 9c5f79d54f
12 changed files with 273 additions and 49 deletions

View file

@ -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.

View file

@ -6,9 +6,8 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
@ -31,18 +30,25 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!--Used by Android Auto-->
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!-- TODO: Can remove in future -->
<!-- <provider-->
<!-- android:name="androidx.core.content.FileProvider"-->
<!-- android:authorities="${applicationId}.fileprovider"-->
<!-- android:exported="false"-->
<!-- android:grantUriPermissions="true">-->
<!-- <meta-data-->
<!-- android:name="android.support.FILE_PROVIDER_PATHS"-->
<!-- android:resource="@xml/file_paths" />-->
<!-- </provider>-->
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter>
@ -51,8 +57,12 @@
</receiver>
<service
android:exported="true"
android:enabled="true"
android:name=".PlayerNotificationService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
</application>

View file

@ -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

View file

@ -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

View file

@ -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<Audiobook>()
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)
}
}

View file

@ -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<Audiobook>()
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<Audiobook>) {
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<MutableList<MediaBrowserCompat.MediaItem>>) {
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = 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)
}
}

View file

@ -0,0 +1,3 @@
<automotiveApp>
<uses name="media" />
</automotiveApp>

View file

@ -10,11 +10,11 @@
</template>
<script>
import Path from 'path'
import { Capacitor } from '@capacitor/core'
import { Network } from '@capacitor/network'
import { AppUpdate } from '@robingenz/capacitor-app-update'
import AudioDownloader from '@/plugins/audio-downloader'
import MyNativeAudio from '@/plugins/my-native-audio'
export default {
data() {
@ -248,6 +248,7 @@ export default {
}
this.checkLoadCurrent()
this.$store.dispatch('audiobooks/setNativeAudiobooks')
},
selectDownload(download) {
this.$store.commit('setPlayOnLoad', true)
@ -373,6 +374,25 @@ export default {
this.checkForUpdate()
this.initMediaStore()
}
// For Testing Android Auto
MyNativeAudio.addListener('onPrepareMedia', (data) => {
var audiobookId = data.audiobookId
var playWhenReady = data.playWhenReady
var audiobook = this.$store.getters['audiobooks/getAudiobook'](audiobookId)
var download = this.$store.getters['downloads/getDownloadIfReady'](audiobookId)
this.$store.commit('setPlayOnLoad', playWhenReady)
if (!download) {
// Stream
this.$store.commit('setStreamAudiobook', audiobook)
this.$server.socket.emit('open_stream', audiobook.id)
} else {
// Local
this.$store.commit('setPlayingDownload', download)
}
})
},
beforeDestroy() {
if (!this.$server) {

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.9.0-beta",
"version": "v0.9.1-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",

View file

@ -46,7 +46,6 @@
<script>
import Path from 'path'
import { Dialog } from '@capacitor/dialog'
import { Capacitor } from '@capacitor/core'
import AudioDownloader from '@/plugins/audio-downloader'
export default {

View file

@ -1,3 +1,4 @@
import MyNativeAudio from '@/plugins/my-native-audio'
import { sort } from '@/assets/fastSort'
import { decode } from '@/plugins/init.client'
@ -60,12 +61,13 @@ export const getters = {
}
export const actions = {
load({ commit }) {
load({ commit, dispatch }) {
return this.$axios
.$get(`/api/audiobooks`)
.then((data) => {
console.log('Audiobooks request data', data)
commit('set', data)
dispatch('setNativeAudiobooks')
})
.catch((error) => {
console.error('Failed', error)
@ -73,6 +75,21 @@ export const actions = {
},
useDownloaded({ commit, rootGetters }) {
commit('set', rootGetters['downloads/getAudiobooks'])
},
setNativeAudiobooks({ state }) {
var audiobooks = state.audiobooks.map(ab => {
var _book = ab.book
return {
id: ab.id,
title: _book.title,
author: _book.author,
duration: ab.duration,
size: ab.size,
cover: _book.cover || '',
series: _book.series || ''
}
})
MyNativeAudio.setAudiobooks({ audiobooks: audiobooks })
}
}

View file

@ -1,5 +1,3 @@
import Vue from 'vue'
export const state = () => ({
user: null,
localUserAudiobooks: {},
@ -31,15 +29,13 @@ export const getters = {
export const actions = {
async updateUserSettings({ commit }, payload) {
if (Vue.prototype.$server.connected) {
if (this.$server.connected) {
var updatePayload = {
...payload
}
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
if (result.success) {
commit('setSettings', result.settings)
console.log('Settings updated', result.settings)
return true
} else {
return false
@ -80,9 +76,7 @@ export const mutations = {
}
}
if (hasChanges) {
console.log('Update settings in local storage')
this.$localStore.setUserSettings({ ...state.settings })
state.settingsListeners.forEach((listener) => {
listener.meth(state.settings)
})