diff --git a/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt b/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt deleted file mode 100644 index 1d3ba0ef..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.audiobookshelf.app - -import android.net.Uri -import android.support.v4.media.MediaMetadataCompat -import com.getcapacitor.JSObject - -class Audiobook { - var id:String - var ino:String - var libraryId:String - var folderId:String - var book:Book - var duration:Float - var size:Long - var numTracks:Int - var isMissing:Boolean - var isInvalid:Boolean - var path:String - - 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 - - id = jsobj.getString("id", "").toString() - ino = jsobj.getString("ino", "").toString() - libraryId = jsobj.getString("libraryId", "").toString() - folderId = jsobj.getString("folderId", "").toString() - - var bookJsObj = jsobj.getJSObject("book") - book = bookJsObj?.let { Book(it) }!! - - duration = jsobj.getDouble("duration").toFloat() - size = jsobj.getLong("size") - numTracks = jsobj.getInteger("numTracks")!! - isMissing = jsobj.getBoolean("isMissing") - isInvalid = jsobj.getBoolean("isInvalid") - path = jsobj.getString("path", "").toString() - - 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():Uri { - if (isDownloaded) { -// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) - return Uri.parse(localCoverUrl) - } - if (book.cover == "" || serverUrl == "" || token == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) - return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}") - } - - 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 deleted file mode 100644 index 31f1e4dc..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt +++ /dev/null @@ -1,230 +0,0 @@ -package com.audiobookshelf.app - -import android.app.Activity -import android.content.Context -import android.content.SharedPreferences -import android.os.Handler -import android.os.Looper -import android.support.v4.media.MediaMetadataCompat - -import android.util.Log -import com.audiobookshelf.app.device.DeviceManager -import com.getcapacitor.JSObject -import okhttp3.* -import org.json.JSONArray -import java.io.IOException - -class AudiobookManager { - var tag = "AudiobookManager" - - var hasLoaded = false - var isLoading = false - var ctx: Context - private var client:OkHttpClient - - var audiobooks:MutableList = mutableListOf() - var audiobooksInProgress:MutableList = mutableListOf() - - constructor(_ctx:Context, _client:OkHttpClient) { - ctx = _ctx - client = _client - } - - fun loadCategories(cb: (() -> Unit)) { - var url = "${DeviceManager.serverAddress}/api/libraries/main/categories" - val request = Request.Builder() - .url(url).addHeader("Authorization", "Bearer ${DeviceManager.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 results = JSONArray(bodyString) -// var results = resJson.getJSONArray("results") - - var totalShelves = results.length() - 1 - Log.d(tag, "Got categories $totalShelves") - for (i in 0..totalShelves) { - var shelfobj = results.get(i) - var jsobj = JSObject(shelfobj.toString()) - var shelfId = jsobj.getString("id", "") - Log.d(tag, "Category shelf id $shelfId") - if (shelfId == "continue-reading") { - var entities = jsobj.getJSONArray("entities") - var totalEntities = entities.length() - 1 - Log.d(tag, "Shelf total entities $totalEntities") - for (y in 0..totalEntities) { - var abobj = entities.get(y) - Log.d(tag, "Shelf category ab id $y = ${abobj.toString()}") - var abjsobj = JSObject(abobj.toString()) - abjsobj.put("isDownloaded", false) - var audiobook = Audiobook(abjsobj, DeviceManager.serverAddress, DeviceManager.token) - if (audiobook.isMissing || audiobook.isInvalid || audiobook.numTracks <= 0) { - Log.d(tag, "Not an audiobook or invalid/missing") - } else { - var audiobookExists = audiobooksInProgress.find { it.id == audiobook.id } - if (audiobookExists == null) { - audiobooksInProgress.add(audiobook) - } - } - } - } - } - Log.d(tag, "${audiobooksInProgress.size} Audiobooks In Progress Loaded") - cb() - } - } - }) - } - - fun loadAudiobooks(cb: (() -> Unit)) { - if (DeviceManager.serverAddress == "" || DeviceManager.token == "") { - Log.d(tag, "Load Audiobooks: No Server or Token set") - cb() - return - } else if (!DeviceManager.serverAddress.startsWith("http")) { - Log.e(tag, "Load Audiobooks: Invalid server url ${DeviceManager.serverAddress}") - cb() - return - } - - // First load currently reading - loadCategories() { - // Then load all - var url = "${DeviceManager.serverAddress}/api/libraries/main/books/all?sort=book.title" - val request = Request.Builder() - .url(url).addHeader("Authorization", "Bearer ${DeviceManager.token}") - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.d(tag, "Load Audiobooks: 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 resJson = JSObject(bodyString) - var results = resJson.getJSONArray("results") - - var totalBooks = results.length() - 1 - for (i in 0..totalBooks) { - var abobj = results.get(i) - var jsobj = JSObject(abobj.toString()) - - jsobj.put("isDownloaded", false) - var audiobook = Audiobook(jsobj, DeviceManager.serverAddress, DeviceManager.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 load() { - isLoading = true - hasLoaded = true - } - - private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { - val lhsLength = lhs.length + 1 - val rhsLength = rhs.length + 1 - - var cost = Array(lhsLength) { it } - var newCost = Array(lhsLength) { 0 } - - for (i in 1..rhsLength-1) { - newCost[0] = i - - for (j in 1..lhsLength-1) { - val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1 - - val costReplace = cost[j - 1] + match - val costInsert = cost[j] + 1 - val costDelete = newCost[j - 1] + 1 - - newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace) - } - - val swap = cost - cost = newCost - newCost = swap - } - - return cost[lhsLength - 1] - } - - fun searchForAudiobook(query:String):Audiobook? { - var closestDistance = 99 - var closestMatch:Audiobook? = null - audiobooks.forEach { - var dist = levenshtein(it.book.title, query) - Log.d(tag, "LEVENSHTEIN $dist") - if (dist < closestDistance) { - closestDistance = dist - closestMatch = it - } - } - if (closestMatch != null) { - Log.d(tag, "Closest Search is ${closestMatch?.book?.title} with distance $closestDistance") - if (closestDistance < 2) { - return closestMatch - } - return null - } - return null - } - - fun getFirstAudiobook():Audiobook? { - return null - } - - // Used for media browser loadChildren, fallback to using the samples if no audiobooks are there - fun getAudiobooksMediaMetadata() : List { - var mediaMetadata:MutableList = mutableListOf() - if (audiobooks.isEmpty()) { - - } else { - audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) } - } - return mediaMetadata - } - // Used for media browser loadChildren, fallback to using the samples if no audiobooks are there - fun getDownloadedAudiobooksMediaMetadata() : List { - var mediaMetadata:MutableList = mutableListOf() - if (audiobooks.isEmpty()) { - - } else { - audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } } - } - return mediaMetadata - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/Book.kt b/android/app/src/main/java/com/audiobookshelf/app/Book.kt deleted file mode 100644 index 2ff4e714..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/Book.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.audiobookshelf.app - -import com.getcapacitor.JSObject - -class Book { - var title:String - var subtitle:String - var author:String - var authorFL:String - var narrator:String - var series:String - var volumeNumber:String - var publisher:String - var description:String - var publishYear:String - var language:String - var cover:String - var coverFullPath:String - var genres:String - var lastUpdate:Long - - constructor(jsobj: JSObject) { - title = jsobj.getString("title", "").toString() - subtitle = jsobj.getString("subtitle", "").toString() - author = jsobj.getString("author", "").toString() - authorFL = jsobj.getString("authorFL", "").toString() - narrator = jsobj.getString("narrator", "").toString() - series = jsobj.getString("series", "").toString() - volumeNumber = jsobj.getString("volumeNumber", "").toString() - publisher = jsobj.getString("publisher", "").toString() - description = jsobj.getString("description", "").toString() - publishYear = jsobj.getString("publishYear", "").toString() - language = jsobj.getString("language", "").toString() - cover = jsobj.getString("cover", "").toString() - coverFullPath = jsobj.getString("coverFullPath", "").toString() - genres = jsobj.getString("genres", "").toString() - lastUpdate = jsobj.getLong("lastUpdate") - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index da7adc41..e91465f2 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -1,5 +1,9 @@ package com.audiobookshelf.app.data +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import com.audiobookshelf.app.R +import com.audiobookshelf.app.device.DeviceManager import com.fasterxml.jackson.annotation.* @JsonIgnoreProperties(ignoreUnknown = true) @@ -21,8 +25,36 @@ data class LibraryItem( var isInvalid:Boolean, var mediaType:String, var media:MediaType, - var libraryFiles:MutableList -) + var libraryFiles:MutableList? +) { + @get:JsonIgnore + val title get() = media.metadata.title + @get:JsonIgnore + val authorName get() = media.metadata.getAuthorDisplayName() + + @JsonIgnore + fun getCoverUri():Uri { + if (media.coverPath == null) { + return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + } + + return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") + } + + @JsonIgnore + fun getMediaMetadata(): MediaMetadataCompat { + return MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString()) + putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString()) + putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) + }.build() + } +} // This auto-detects whether it is a Book or Podcast @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @@ -46,36 +78,36 @@ class Podcast( metadata:PodcastMetadata, coverPath:String?, var tags:MutableList, - var episodes:MutableList, + var episodes:MutableList?, var autoDownloadEpisodes:Boolean ) : MediaType(metadata, coverPath) { @JsonIgnore override fun getAudioTracks():List { - var tracks = episodes.map { it.audioTrack } - return tracks.filterNotNull() + var tracks = episodes?.map { it.audioTrack } + return tracks?.filterNotNull() ?: mutableListOf() } @JsonIgnore override fun setAudioTracks(audioTracks:MutableList) { // Remove episodes no longer there in tracks - episodes = episodes.filter { ep -> + episodes = episodes?.filter { ep -> audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null } as MutableList // Add new episodes audioTracks.forEach { at -> - if (episodes.find{ it.audioTrack?.localFileId == at.localFileId } == null) { - var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes.size + 1,null,null,at.title,null,null,null,at) - episodes.add(newEpisode) + if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { + var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at) + episodes?.add(newEpisode) } } } @JsonIgnore override fun addAudioTrack(audioTrack:AudioTrack) { - var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes.size + 1,null,null,audioTrack.title,null,null,null,audioTrack) - episodes.add(newEpisode) + var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack) + episodes?.add(newEpisode) } @JsonIgnore override fun removeAudioTrack(localFileId:String) { - episodes.removeIf { it.audioTrack?.localFileId == localFileId } + episodes?.removeIf { it.audioTrack?.localFileId == localFileId } } } @@ -84,8 +116,8 @@ class Book( metadata:BookMetadata, coverPath:String?, var tags:List, - var audioFiles:List, - var chapters:List, + var audioFiles:List?, + var chapters:List?, var tracks:MutableList?, var size:Long?, var duration:Double? @@ -136,20 +168,23 @@ class Book( } } -// This auto-detects whether it is a Book or Podcast +// This auto-detects whether it is a BookMetadata or PodcastMetadata @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @JsonSubTypes( JsonSubTypes.Type(BookMetadata::class), JsonSubTypes.Type(PodcastMetadata::class) ) -open class MediaTypeMetadata(var title:String) {} +open class MediaTypeMetadata(var title:String) { + @JsonIgnore + open fun getAuthorDisplayName():String { return "Unknown" } +} @JsonIgnoreProperties(ignoreUnknown = true) class BookMetadata( title:String, var subtitle:String?, - var authors:MutableList, - var narrators:MutableList, + var authors:MutableList?, + var narrators:MutableList?, var genres:MutableList, var publishedYear:String?, var publishedDate:String?, @@ -164,7 +199,10 @@ class BookMetadata( var authorNameLF:String?, var narratorName:String?, var seriesName:String? -) : MediaTypeMetadata(title) +) : MediaTypeMetadata(title) { + @JsonIgnore + override fun getAuthorDisplayName():String { return authorName ?: "Unknown" } +} @JsonIgnoreProperties(ignoreUnknown = true) class PodcastMetadata( @@ -172,7 +210,10 @@ class PodcastMetadata( var author:String?, var feedUrl:String?, var genres:MutableList -) : MediaTypeMetadata(title) +) : MediaTypeMetadata(title) { + @JsonIgnore + override fun getAuthorDisplayName():String { return author ?: "Unknown" } +} @JsonIgnoreProperties(ignoreUnknown = true) data class Author( diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index 8ba1f334..01b8bcc7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -68,7 +68,7 @@ data class LocalLibraryItem( var episodeIdNullable = if (episodeId.isNullOrEmpty()) null else episodeId var dateNow = System.currentTimeMillis() - return PlaybackSession(sessionId,serverUserId,libraryItemId,episodeIdNullable, mediaType, mediaMetadata, chapters, mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, media.getAudioTracks() as MutableList,currentTime,null,this,serverConnectionConfigId, serverAddress) + return PlaybackSession(sessionId,serverUserId,libraryItemId,episodeIdNullable, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, media.getAudioTracks() as MutableList,currentTime,null,this,serverConnectionConfigId, serverAddress) } @JsonIgnore diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt new file mode 100644 index 00000000..94366594 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -0,0 +1,70 @@ +package com.audiobookshelf.app.media + +import com.audiobookshelf.app.data.LibraryItem +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.server.ApiHandler +import java.util.* + +class MediaManager(var apiHandler: ApiHandler) { + var serverLibraryItems = listOf() + + fun loadLibraryItems(cb: (List) -> Unit) { + if (serverLibraryItems.isNotEmpty()) { + cb(serverLibraryItems) + } else { + apiHandler.getLibraryItems("main") { libraryItems -> + serverLibraryItems = libraryItems + cb(libraryItems) + } + } + } + + fun getFirstItem() : LibraryItem? { + return if (serverLibraryItems.isNotEmpty()) serverLibraryItems[0] else null + } + + fun getById(id:String) : LibraryItem? { + return serverLibraryItems.find { it.id == id } + } + + fun getFromSearch(query:String?) : LibraryItem? { + if (query.isNullOrEmpty()) return getFirstItem() + return serverLibraryItems.find { + it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault())) + } + } + + fun play(libraryItem:LibraryItem, cb: (PlaybackSession) -> Unit) { + apiHandler.playLibraryItem(libraryItem.id,"",false) { + cb(it) + } + } + + private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { + val lhsLength = lhs.length + 1 + val rhsLength = rhs.length + 1 + + var cost = Array(lhsLength) { it } + var newCost = Array(lhsLength) { 0 } + + for (i in 1..rhsLength-1) { + newCost[0] = i + + for (j in 1..lhsLength-1) { + val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1 + + val costReplace = cost[j - 1] + match + val costInsert = cost[j] + 1 + val costDelete = newCost[j - 1] + 1 + + newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace) + } + + val swap = cost + cost = newCost + newCost = swap + } + + return cost[lhsLength - 1] + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index 4053fe11..68569ed7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -6,14 +6,14 @@ import android.net.Uri import android.support.v4.media.MediaMetadataCompat import android.util.Log import androidx.annotation.AnyRes -import com.audiobookshelf.app.Audiobook import com.audiobookshelf.app.R +import com.audiobookshelf.app.data.LibraryItem class BrowseTree( val context: Context, - audiobooksInProgress: List, - audiobookMetadata: List, + itemsInProgress: List, + itemsMetadata: List, downloadedMetadata: List ) { private val mediaIdToChildren = mutableMapOf>() @@ -35,7 +35,6 @@ class BrowseTree( init { val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() - val continueReadingMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Reading") @@ -44,27 +43,20 @@ class BrowseTree( val allMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT) - putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Audiobooks") + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items") 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 downloadsMetadata = 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() -// val localsMetadata = MediaMetadataCompat.Builder().apply { -// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT) -// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples") -// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) -// }.build() - - if (audiobooksInProgress.isNotEmpty()) { + if (itemsInProgress.isNotEmpty()) { rootList += continueReadingMetadata } rootList += allMetadata @@ -72,13 +64,13 @@ class BrowseTree( // rootList += localsMetadata mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList - audiobooksInProgress.forEach { audiobook -> + itemsInProgress.forEach { libraryItem -> val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf() - children += audiobook.toMediaMetadata() + children += libraryItem.getMediaMetadata() mediaIdToChildren[CONTINUE_ROOT] = children } - audiobookMetadata.forEach { + itemsMetadata.forEach { val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf() allChildren += it mediaIdToChildren[ALL_ROOT] = allChildren @@ -89,13 +81,6 @@ class BrowseTree( allChildren += it mediaIdToChildren[DOWNLOADS_ROOT] = allChildren } - -// localAudio.forEach { local -> -// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf() -// localChildren += local.toMediaMetadata() -// mediaIdToChildren[LOCAL_ROOT] = localChildren -// } -// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}") } operator fun get(mediaId: String) = mediaIdToChildren[mediaId] diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt index 36458548..e5e53315 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt @@ -304,7 +304,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic val castContext = CastContext.getSharedInstance(mainActivity) playerNotificationService.castPlayer = CastPlayer(castContext).apply { setSessionAvailabilityListener(CastSessionAvailabilityListener()) - addListener(playerNotificationService.getPlayerListener()) + addListener(PlayerListener(playerNotificationService)) } Log.d(tag, "CAST Cast Player Applied") switchToPlayer(true) @@ -313,8 +313,6 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic "Exception thrown when attempting to obtain CastContext. " + e.message) return } - - // media.setSession(castSession) // callback.onJoin(ChromecastUtilities.createSessionObject(castSession)) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt new file mode 100644 index 00000000..b30c5007 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -0,0 +1,194 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.support.v4.media.session.MediaSessionCompat +import android.util.Log +import android.view.KeyEvent +import com.audiobookshelf.app.data.LibraryItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.util.* +import kotlin.concurrent.schedule + +class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() { + var tag = "MediaSessionCallback" + + private var mediaButtonClickCount: Int = 0 + var mediaButtonClickTimeout: Long = 1000 //ms + var seekAmount: Long = 20000 //ms + + override fun onPrepare() { + Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") + playerNotificationService.mediaManager.getFirstItem()?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,true) + } + } + } + } + + override fun onPlay() { + Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") + playerNotificationService.play() + } + + override fun onPrepareFromSearch(query: String?, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM SEARCH $query") + super.onPrepareFromSearch(query, extras) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + Log.d(tag, "ON PLAY FROM SEARCH $query") + playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,true) + } + } + } + } + + override fun onPause() { + Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT") + playerNotificationService.pause() + } + + override fun onStop() { + playerNotificationService.pause() + } + + override fun onSkipToPrevious() { + playerNotificationService.seekBackward(seekAmount) + } + + override fun onSkipToNext() { + playerNotificationService.seekForward(seekAmount) + } + + override fun onFastForward() { + playerNotificationService.seekForward(seekAmount) + } + + override fun onRewind() { + playerNotificationService.seekForward(seekAmount) + } + + override fun onSeekTo(pos: Long) { + playerNotificationService.seekPlayer(pos) + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") + var libraryItem: LibraryItem? = null + if (mediaId.isNullOrEmpty()) { + libraryItem = playerNotificationService.mediaManager.getFirstItem() + } else { + libraryItem = playerNotificationService.mediaManager.getById(mediaId) + } + + libraryItem?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,true) + } + } + } + } + + 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 (playerNotificationService.mPlayer.isPlaying) + playerNotificationService.pause() + else + playerNotificationService.play() + } + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PLAY -> { + if (0 == mediaButtonClickCount) { + playerNotificationService.play() + playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer() + } + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + if (0 == mediaButtonClickCount) playerNotificationService.pause() + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_NEXT -> { + playerNotificationService.seekForward(seekAmount) + } + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { + playerNotificationService.seekBackward(seekAmount) + } + KeyEvent.KEYCODE_MEDIA_STOP -> { + playerNotificationService.terminateStream() + } + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + if (playerNotificationService.mPlayer.isPlaying) { + if (0 == mediaButtonClickCount) playerNotificationService.pause() + handleMediaButtonClickCount() + } else { + if (0 == mediaButtonClickCount) { + playerNotificationService.play() + playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer() + } + handleMediaButtonClickCount() + } + } + 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) { + playerNotificationService.seekBackward(seekAmount) + playerNotificationService.play() + } + else if (msg.what >= 3) { + playerNotificationService.seekForward(seekAmount) + playerNotificationService.play() + } + } + } + +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt new file mode 100644 index 00000000..e04ec0b9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt @@ -0,0 +1,70 @@ +package com.audiobookshelf.app.player + +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ResultReceiver +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import com.audiobookshelf.app.data.LibraryItem +import com.google.android.exoplayer2.ControlDispatcher +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector + +class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer { + var tag = "MediaSessionPlaybackPreparer" + + 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") + playerNotificationService.mediaManager.getFirstItem()?.let { li -> + playerNotificationService.mediaManager.play(li) { + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,playWhenReady) + } + } + } + } + + override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") + + var libraryItem: LibraryItem? = playerNotificationService.mediaManager.getById(mediaId) + libraryItem?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,playWhenReady) + } + } + } + } + + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM SEARCH $query") + playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,playWhenReady) + } + } + } + } + + override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM URI $uri") + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt new file mode 100644 index 00000000..2c5400ad --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -0,0 +1,102 @@ +package com.audiobookshelf.app.player + +import android.util.Log +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player + +class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { + var tag = "PlayerListener" + + companion object { + var lastPauseTime: Long = 0 //ms + } + + private var onSeekBack: Boolean = false + + override fun onPlayerError(error: PlaybackException) { + error.message?.let { Log.e(tag, it) } + error.localizedMessage?.let { Log.e(tag, it) } + } + + override fun onEvents(player: Player, events: Player.Events) { + if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { + Log.d(tag, "EVENT_POSITION_DISCONTINUITY") + } + + if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) { + Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.mPlayer.isLoading.toString()) + } + + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) { + Log.d(tag, "STATE_READY : " + playerNotificationService.mPlayer.duration.toString()) + + if (lastPauseTime == 0L) { + playerNotificationService.sendClientMetadata("ready_no_sync") + lastPauseTime = -1; + } else playerNotificationService.sendClientMetadata("ready") + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) { + Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.mPlayer.currentPosition.toString()) + if (lastPauseTime == 0L) playerNotificationService.sendClientMetadata("buffering_no_sync") + else playerNotificationService.sendClientMetadata("buffering") + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) { + Log.d(tag, "STATE_ENDED") + playerNotificationService.sendClientMetadata("ended") + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) { + Log.d(tag, "STATE_IDLE") + playerNotificationService.sendClientMetadata("idle") + } + } + + if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) { + Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED") + } + if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { + Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED") + } + if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { + Log.d(tag, "EVENT IS PLAYING CHANGED") + + if (player.isPlaying) { + if (lastPauseTime > 0) { + if (onSeekBack) onSeekBack = false + else { + var backTime = calcPauseSeekBackTime() + if (backTime > 0) { + if (backTime >= playerNotificationService.mPlayer.currentPosition) backTime = playerNotificationService.mPlayer.currentPosition - 500 + Log.d(tag, "SeekBackTime $backTime") + onSeekBack = true + playerNotificationService.seekBackward(backTime) + } + } + } + } else lastPauseTime = System.currentTimeMillis() + + // Start/stop progress sync interval + Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}") + if (player.isPlaying) { + playerNotificationService.mediaProgressSyncer.start() + } else { + playerNotificationService.mediaProgressSyncer.stop() + } + + playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying) + } + } + + fun calcPauseSeekBackTime() : Long { + if (lastPauseTime <= 0) return 0 + var time: Long = System.currentTimeMillis() - lastPauseTime + var seekback: Long = 0 + if (time < 60000) seekback = 0 + else if (time < 120000) seekback = 10000 + else if (time < 300000) seekback = 15000 + else if (time < 1800000) seekback = 20000 + else if (time < 3600000) seekback = 25000 + else seekback = 29500 + return seekback + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt new file mode 100644 index 00000000..d1db2bdc --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt @@ -0,0 +1,31 @@ +package com.audiobookshelf.app.player + +import android.app.Notification +import android.util.Log +import com.google.android.exoplayer2.ui.PlayerNotificationManager + +class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener { + var tag = "PlayerNotificationListener" + + override fun onNotificationPosted( + notificationId: Int, + notification: Notification, + onGoing: Boolean) { + + // Start foreground service + Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") + playerNotificationService.startForeground(notificationId, notification) + } + + override fun onNotificationCancelled( + notificationId: Int, + dismissedByUser: Boolean + ) { + if (dismissedByUser) { + Log.d(tag, "onNotificationCancelled dismissed by user") + playerNotificationService.stopSelf() + } else { + Log.d(tag, "onNotificationCancelled not dismissed by user") + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 73b5c4c2..035a6086 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -1,13 +1,11 @@ package com.audiobookshelf.app.player -import android.annotation.SuppressLint import android.app.* import android.content.Context import android.content.Intent import android.graphics.Color import android.hardware.Sensor import android.hardware.SensorManager -import android.net.Uri import android.os.* import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat @@ -16,18 +14,16 @@ 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 android.view.KeyEvent import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants -import com.audiobookshelf.app.Audiobook -import com.audiobookshelf.app.AudiobookManager +import com.audiobookshelf.app.data.LibraryItem import com.audiobookshelf.app.data.LocalMediaProgress import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.server.ApiHandler -import com.getcapacitor.Bridge import com.getcapacitor.JSObject import com.google.android.exoplayer2.* import com.google.android.exoplayer2.audio.AudioAttributes @@ -72,7 +68,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var mediaSession: MediaSessionCompat private lateinit var transportControls:MediaControllerCompat.TransportControls - private lateinit var audiobookManager: AudiobookManager + lateinit var mediaManager: MediaManager lateinit var apiHandler: ApiHandler lateinit var mPlayer: SimpleExoPlayer @@ -89,15 +85,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var currentPlaybackSession:PlaybackSession? = null - private var mediaButtonClickCount: Int = 0 - var mediaButtonClickTimeout: Long = 1000 //ms - var seekAmount: Long = 20000 //ms - - private var lastPauseTime: Long = 0 //ms - private var onSeekBack: Boolean = false - var isAndroidAuto = false - var webviewBridge:Bridge? = null // The following are used for the shake detection private var isShakeSensorRegistered:Boolean = false @@ -106,13 +94,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var mShakeDetector: ShakeDetector? = null private var shakeSensorUnregisterTask:TimerTask? = null - fun setBridge(bridge: Bridge) { - webviewBridge = bridge - } - fun getIsWebviewOpen():Boolean { - return webviewBridge?.app?.isActive == true - } - /* Service related stuff */ @@ -146,7 +127,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { override fun onStart(intent: Intent?, startId: Int) { Log.d(tag, "onStart $startId") - } @RequiresApi(Build.VERSION_CODES.O) @@ -160,41 +140,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { return channelId } - private fun playAudiobookFromMediaBrowser(audiobook: Audiobook, playWhenReady: Boolean) { - - } - - private fun playFirstAudiobook(playWhenReady: Boolean) { - - } - - private fun openFromMediaId(mediaId: String, playWhenReady: Boolean) { - var audiobook = audiobookManager.audiobooks.find { it.id == mediaId } - if (audiobook == null) { - Log.e(tag, "Audiobook NOT FOUND") - return - } - - playAudiobookFromMediaBrowser(audiobook, playWhenReady) - } - - private fun openFromSearch(query: String?, playWhenReady: Boolean) { - if (query?.isNullOrEmpty() == true) { - Log.d(tag, "Empty search query play first audiobook") - playFirstAudiobook(playWhenReady) - return - } - - var audiobook = audiobookManager.searchForAudiobook(query) - if (audiobook == null) { - Log.e(tag, "No Audiobook found for search $query") - pause() - return - } - - playAudiobookFromMediaBrowser(audiobook, playWhenReady) - } - // detach player override fun onDestroy() { playerNotificationManager.setPlayer(null) @@ -233,7 +178,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { simpleExoPlayerBuilder.setSeekForwardIncrementMs(10000) mPlayer = simpleExoPlayerBuilder.build() mPlayer.setHandleAudioBecomingNoisy(true) - mPlayer.addListener(getPlayerListener()) + mPlayer.addListener(PlayerListener(this)) var audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build() mPlayer.setAudioAttributes(audioAttributes, true) @@ -257,8 +202,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}") initSensor() - // Initialize audiobook manager - audiobookManager = AudiobookManager(ctx, client) + // Initialize media manager + mediaManager = MediaManager(apiHandler) channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createNotificationChannel(channelId, channelName) @@ -289,30 +234,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { channelId) builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this)) - - builder.setNotificationListener(object : PlayerNotificationManager.NotificationListener { - override fun onNotificationPosted( - notificationId: Int, - notification: Notification, - onGoing: Boolean) { - - // Start foreground service - Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") - startForeground(notificationId, notification) - } - - override fun onNotificationCancelled( - notificationId: Int, - dismissedByUser: Boolean - ) { - if (dismissedByUser) { - Log.d(tag, "onNotificationCancelled dismissed by user") - stopSelf() - } else { - Log.d(tag, "onNotificationCancelled not dismissed by user") - } - } - }) + builder.setNotificationListener(PlayerNotificationListener(this)) playerNotificationManager = builder.build() playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken) @@ -330,13 +252,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { transportControls = mediaController.transportControls - // Color is set based on the art - cannot override -// playerNotificationManager.setColor(Color.RED) -// playerNotificationManager.setColorized(true) - - // Icon needs to be black and white -// playerNotificationManager.setSmallIcon(R.drawable.icon_32) - mediaSessionConnector = MediaSessionConnector(mediaSession) val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { @@ -350,39 +265,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // .setMediaUri(currentPlaybackSession!!.getContentUri()) } - 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") - playFirstAudiobook(playWhenReady) - } - - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") - openFromMediaId(mediaId, playWhenReady) - } - - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM SEARCH $query") - openFromSearch(query, playWhenReady) - } - - override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM URI $uri") - } - } - mediaSessionConnector.setEnabledPlaybackActions( PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY @@ -393,234 +275,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { or PlaybackStateCompat.ACTION_STOP ) mediaSessionConnector.setQueueNavigator(queueNavigator) - mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer) + mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) mediaSessionConnector.setPlayer(mPlayer) //attach player to playerNotificationManager playerNotificationManager.setPlayer(mPlayer) - - mediaSession.setCallback(object : MediaSessionCompat.Callback() { - override fun onPrepare() { - Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") - playFirstAudiobook(true) - } - - override fun onPlay() { - Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") - play() - } - - override fun onPrepareFromSearch(query: String?, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM SEARCH $query") - super.onPrepareFromSearch(query, extras) - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - Log.d(tag, "ON PLAY FROM SEARCH $query") - openFromSearch(query, true) - } - - override fun onPause() { - Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT") - pause() - } - - override fun onStop() { - pause() - } - - override fun onSkipToPrevious() { - seekBackward(seekAmount) - } - - override fun onSkipToNext() { - seekForward(seekAmount) - } - - override fun onFastForward() { - seekForward(seekAmount) - } - - override fun onRewind() { - seekForward(seekAmount) - } - - override fun onSeekTo(pos: Long) { - seekPlayer(pos) - } - - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") - if (mediaId.isNullOrEmpty()) { - playFirstAudiobook(true) - return - } - openFromMediaId(mediaId, true) - } - - 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() - sleepTimerManager.checkShouldExtendSleepTimer() - } - 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() - } - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { - if (mPlayer.isPlaying) { - if (0 == mediaButtonClickCount) pause() - handleMediaButtonClickCount() - } else { - if (0 == mediaButtonClickCount) { - play() - sleepTimerManager.checkShouldExtendSleepTimer() - } - handleMediaButtonClickCount() - } - } - 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() - } - } - } - - fun getPlayerListener(): Player.Listener { - return object : Player.Listener { - override fun onPlayerError(error: PlaybackException) { - error.message?.let { Log.e(tag, it) } - error.localizedMessage?.let { Log.e(tag, it) } - } - - override fun onEvents(player: Player, events: Player.Events) { - if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { - Log.d(tag, "EVENT_POSITION_DISCONTINUITY") - } - - if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) { - Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + mPlayer.isLoading.toString()) - } - - if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { - if (currentPlayer.playbackState == Player.STATE_READY) { - Log.d(tag, "STATE_READY : " + mPlayer.duration.toString()) - - if (lastPauseTime == 0L) { - sendClientMetadata("ready_no_sync") - lastPauseTime = -1; - } else sendClientMetadata("ready") - } - if (currentPlayer.playbackState == Player.STATE_BUFFERING) { - Log.d(tag, "STATE_BUFFERING : " + mPlayer.currentPosition.toString()) - if (lastPauseTime == 0L) sendClientMetadata("buffering_no_sync") - else sendClientMetadata("buffering") - } - if (currentPlayer.playbackState == Player.STATE_ENDED) { - Log.d(tag, "STATE_ENDED") - sendClientMetadata("ended") - } - if (currentPlayer.playbackState == Player.STATE_IDLE) { - Log.d(tag, "STATE_IDLE") - sendClientMetadata("idle") - } - } - - if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) { - Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED") - } - if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { - Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED") - } - if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { - Log.d(tag, "EVENT IS PLAYING CHANGED") - - if (player.isPlaying) { - if (lastPauseTime > 0) { - if (onSeekBack) onSeekBack = false - else { - var backTime = calcPauseSeekBackTime() - if (backTime > 0) { - if (backTime >= mPlayer.currentPosition) backTime = mPlayer.currentPosition - 500 - Log.d(tag, "SeekBackTime $backTime") - onSeekBack = true - seekBackward(backTime) - } - } - } - } else lastPauseTime = System.currentTimeMillis() - - // Start/stop progress sync interval - Log.d(tag, "Playing ${getCurrentBookTitle()} | ${currentPlayer.mediaMetadata.title} | ${currentPlayer.mediaMetadata.displayTitle}") - if (player.isPlaying) { - mediaProgressSyncer.start() - } else { - mediaProgressSyncer.stop() - } - - clientEventEmitter?.onPlayingUpdate(player.isPlaying) - } - } - } + mediaSession.setCallback(MediaSessionCallback(this)) } /* @@ -668,9 +328,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } else { mPlayer.seekTo(playbackSession.currentTimeMs) } - - - } else if (castPlayer != null) { //// var mediaQueue = currentAudiobookStreamData!!.getCastQueue() // // TODO: Start position will need to be adjusted if using multi-track queue @@ -681,68 +338,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { currentPlayer.setPlaybackSpeed(1f) // TODO: Playback speed should come from settings currentPlayer.prepare() } -// -// fun initPlayer(audiobookStreamData: AudiobookStreamData) { -// currentAudiobookStreamData = audiobookStreamData -// -// Log.d(tag, "Init Player Audiobook ${currentAudiobookStreamData!!.playlistUrl} | ${currentAudiobookStreamData!!.title} | ${currentAudiobookStreamData!!.author}") -// -// if (mPlayer.isPlaying) { -// Log.d(tag, "Init Player audiobook already playing") -// } -// -// // Issue with onenote plus crashing when using local cover art. https://github.com/advplyr/audiobookshelf-app/issues/35 -// // Same issue with sony xperia https://github.com/advplyr/audiobookshelf-app/issues/94 -// if (currentAudiobookStreamData?.coverUri != null && currentAudiobookStreamData?.isLocal == true) { -// var deviceName = Build.DEVICE -// var deviceMan = Build.MANUFACTURER -// var deviceModel = Build.MODEL -// Log.d(tag, "Checking device $deviceName | Model $deviceModel | Manufacturer $deviceMan") -// if (deviceMan.lowercase(Locale.getDefault()).contains("oneplus") || deviceName.lowercase(Locale.getDefault()).contains("oneplus")) { -// Log.d(tag, "Detected OnePlus device - removing local cover") -// currentAudiobookStreamData?.clearCover() -// } else if (deviceName.lowercase(Locale.getDefault()).contains("xperia") || deviceModel.lowercase(Locale.getDefault()).contains("xperia")) { -// Log.d(tag, "Detected Sony Xperia device - removing local cover") -// currentAudiobookStreamData?.clearCover() -// } -// } -// -// var metadata = currentAudiobookStreamData!!.getMediaMetadataCompat() -// mediaSession.setMetadata(metadata) -// -// var mediaUri:Uri = currentAudiobookStreamData!!.getMediaUri() -// var mimeType:String = currentAudiobookStreamData!!.getMimeType() -// -// var mediaMetadata = currentAudiobookStreamData!!.getMediaMetadata() -// var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() -// -// if (mPlayer == currentPlayer) { -// var mediaSource:MediaSource -// -// if (currentAudiobookStreamData!!.isLocal) { -// Log.d(tag, "Playing Local File") -// var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) -// mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) -// } else { -// Log.d(tag, "Playing HLS File") -// var dataSourceFactory = DefaultHttpDataSource.Factory() -// dataSourceFactory.setUserAgent(channelId) -// dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobookStreamData!!.token}")) -// mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) -// } -// mPlayer.setMediaSource(mediaSource, currentAudiobookStreamData!!.startTime) -// } else if (castPlayer != null) { -// var mediaQueue = currentAudiobookStreamData!!.getCastQueue() -// // TODO: Start position will need to be adjusted if using multi-track queue -// castPlayer?.setMediaItems(mediaQueue, 0, 0) -// } -// -// currentPlayer.prepare() -// currentPlayer.playWhenReady = currentAudiobookStreamData!!.playWhenReady -// currentPlayer.setPlaybackSpeed(audiobookStreamData.playbackSpeed) -// -// lastPauseTime = 0 -// } fun switchToPlayer(useCastPlayer: Boolean) { currentPlayer = if (useCastPlayer) { @@ -784,10 +379,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } - fun getTheLastPauseTime() : Long { - return lastPauseTime - } - fun getDuration() : Long { return currentPlayer.duration } @@ -804,19 +395,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { return currentPlaybackSession?.id } - fun calcPauseSeekBackTime() : Long { - if (lastPauseTime <= 0) return 0 - var time: Long = System.currentTimeMillis() - lastPauseTime - var seekback: Long = 0 - if (time < 60000) seekback = 0 - else if (time < 120000) seekback = 10000 - else if (time < 300000) seekback = 15000 - else if (time < 1800000) seekback = 20000 - else if (time < 3600000) seekback = 25000 - else seekback = 29500 - return seekback - } - fun play() { if (currentPlayer.isPlaying) { Log.d(tag, "Already playing") @@ -859,8 +437,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun terminateStream() { currentPlayer.clearMediaItems() currentPlaybackSession = null - lastPauseTime = 0 clientEventEmitter?.onPlaybackClosed() + PlayerListener.lastPauseTime = 0 } fun sendClientMetadata(stateName: String) { @@ -926,93 +504,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - if (!audiobookManager.hasLoaded) { - result.detach() - audiobookManager.load() - audiobookManager.loadAudiobooks() { - audiobookManager.isLoading = false - - Log.d(tag, "LOADED AUDIOBOOKS") - - var audiobooks:List = audiobookManager.getAudiobooksMediaMetadata() - var downloadedBooks:List = audiobookManager.getDownloadedAudiobooksMediaMetadata() - - browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks) - val children = browseTree[parentMediaId]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, flag) - } - result.sendResult(children as MutableList?) + result.detach() + mediaManager.loadLibraryItems { libraryItems -> + var itemMediaMetadata:List = libraryItems.map { it.getMediaMetadata() } + browseTree = BrowseTree(this, mutableListOf(), itemMediaMetadata, mutableListOf()) + val children = browseTree[parentMediaId]?.map { item -> + MediaBrowserCompat.MediaItem(item.description, flag) } - return - } else if (audiobookManager.isLoading) { - Log.d(tag, "AUDIOBOOKS LOADING") - result.detach() - return + result.sendResult(children as MutableList?) } - 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?) - // TODO: For using sub menus. Check if this is the root menu: - if (AUTO_MEDIA_ROOT == parentMediaId) { +// if (AUTO_MEDIA_ROOT == parentMediaId) { // build the MediaItem objects for the top level, // and put them in the mediaItems list - } else { +// } else { // examine the passed parentMediaId to see which submenu we're at, // and put the children of that menu in the mediaItems list - } +// } } override fun onSearch(query: String, extras: Bundle?, result: Result>) { - val mediaItems: MutableList = mutableListOf() - - if (!audiobookManager.hasLoaded) { - result.detach() - audiobookManager.load() - audiobookManager.loadAudiobooks() { - audiobookManager.isLoading = false - - Log.d(tag, "LOADED AUDIOBOOKS") - var audiobooks:List = audiobookManager.getAudiobooksMediaMetadata() - var downloadedBooks:List = audiobookManager.getDownloadedAudiobooksMediaMetadata() - - browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks) - val children = browseTree[ALL_ROOT]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - } - if (children != null) { - Log.d(tag, "BROWSE TREE CHILDREN ${children.size}") - } - result.sendResult(children as MutableList?) + result.detach() + mediaManager.loadLibraryItems { libraryItems -> + var itemMediaMetadata:List = libraryItems.map { it.getMediaMetadata() } + browseTree = BrowseTree(this, mutableListOf(), itemMediaMetadata, mutableListOf()) + val children = browseTree[ALL_ROOT]?.map { item -> + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } - return - } else if (audiobookManager.isLoading) { - Log.d(tag, "AUDIOBOOKS LOADING") - result.detach() - return + result.sendResult(children as MutableList?) } - - if (audiobookManager.audiobooks.size == 0) { - Log.d(tag, "AudiobookManager: Sending no items") - result.sendResult(mediaItems) - return - } - - val children = browseTree[ALL_ROOT]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - } - if (children != null) { - Log.d(tag, "NO CHILDREN ON SEARCH ${children.size}") - } - result.sendResult(children as MutableList?) } - // // SHAKE SENSOR // diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 76962cda..d97cf149 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -32,8 +32,6 @@ class AbsAudioPlayer : Plugin() { var foregroundServiceReady : () -> Unit = { playerNotificationService = mainActivity.foregroundService - playerNotificationService.setBridge(bridge) - playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter { override fun onPlaybackSession(playbackSession: PlaybackSession) { notifyListeners("onPlaybackSession", JSObject(jacksonObjectMapper().writeValueAsString(playbackSession))) diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 3fc50786..d195e475 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -127,7 +127,7 @@ class ApiHandler { } fun getLibraryItems(libraryId:String, cb: (List) -> Unit) { - getRequest("/api/libraries/$libraryId/items") { + getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") { val items = mutableListOf() if (it.has("results")) { var array = it.getJSONArray("results") diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index fd5eafc4..a99df0e9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - AudioBookshelf - AudioBookshelf + audiobookshelf + audiobookshelf com.audiobookshelf.app com.audiobookshelf.app