Merge branch 'advplyr:master' into vangorra/aa-button-cleanup

This commit is contained in:
Robert Van Gorkom
2022-09-04 09:23:51 -07:00
committed by GitHub
42 changed files with 1465 additions and 814 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 87 versionCode 88
versionName "0.9.56-beta" versionName "0.9.57-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -1,69 +1,11 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.net.Uri import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.* import com.fasterxml.jackson.annotation.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItem(
var id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper() {
@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 checkHasTracks():Boolean {
return if (mediaType == "podcast") {
((media as Podcast).numEpisodes ?: 0) > 0
} else {
((media as Book).numTracks ?: 0) > 0
}
}
@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_AUTHOR, authorName)
}.build()
}
}
// This auto-detects whether it is a Book or Podcast // This auto-detects whether it is a Book or Podcast
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes( @JsonSubTypes(
@@ -121,7 +63,7 @@ class Podcast(
} }
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { override fun addAudioTrack(audioTrack:AudioTrack) {
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null) val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -142,7 +84,7 @@ class Podcast(
} }
@JsonIgnore @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id) val newEpisode = PodcastEpisode("local_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -293,24 +235,51 @@ data class PodcastEpisode(
var serverEpisodeId:String? // For local podcasts to match with server podcasts var serverEpisodeId:String? // For local podcasts to match with server podcasts
) { ) {
@JsonIgnore @JsonIgnore
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat { fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
var coverUri:Uri = Uri.EMPTY val coverUri = if(libraryItem is LocalLibraryItem) {
val podcast = if(libraryItem is LocalLibraryItem) { libraryItem.getCoverUri()
coverUri = libraryItem.getCoverUri()
libraryItem.media as Podcast
} else { } else {
coverUri = (libraryItem as LibraryItem).getCoverUri() (libraryItem as LibraryItem).getCoverUri()
(libraryItem as LibraryItem).media as Podcast
} }
return MediaMetadataCompat.Builder().apply { val extras = Bundle()
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) if (progress != null) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) if (progress.isFinished) {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) extras.putInt(
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName()) MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
}.build() } else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
// 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_AUTHOR, podcast.metadata.getAuthorDisplayName())
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
//
// }.build()
val libraryItemDescription = libraryItem.getMediaDescription(null)
return MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title)
.setExtras(extras)
.build()
} }
} }
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.support.v4.media.MediaDescriptionCompat
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
@@ -92,7 +93,10 @@ data class LocalFolder(
JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LibraryItem::class),
JsonSubTypes.Type(LocalLibraryItem::class) JsonSubTypes.Type(LocalLibraryItem::class)
) )
open class LibraryItemWrapper() open class LibraryItemWrapper(var id:String) {
@JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo( data class DeviceInfo(
@@ -0,0 +1,34 @@
/*
Used in Android Auto to represent a podcast episode or an audiobook in progress
*/
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.json.JSONObject
@JsonIgnoreProperties(ignoreUnknown = true)
data class ItemInProgress(
val libraryItemWrapper: LibraryItemWrapper,
val episode: PodcastEpisode?,
val progressLastUpdate: Long,
val isLocal: Boolean
) {
companion object {
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
var episode:PodcastEpisode? = null
if (serverItem.has("recentEpisode")) {
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
}
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
}
}
}
@@ -0,0 +1,90 @@
package com.audiobookshelf.app.data
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class LibraryItem(
id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper(id) {
@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 checkHasTracks():Boolean {
return if (mediaType == "podcast") {
((media as Podcast).numEpisodes ?: 0) > 0
} else {
((media as Book).numTracks ?: 0) > 0
}
}
@JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
val extras = Bundle()
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else if (mediaType != "podcast") {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
return MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(getCoverUri())
.setSubtitle(authorName)
.setExtras(extras)
.build()
}
}
@@ -1,10 +1,8 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
@@ -14,8 +12,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.* import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LocalLibraryItem( class LocalLibraryItem(
var id:String, id:String,
var folderId:String, var folderId:String,
var basePath:String, var basePath:String,
var absolutePath:String, var absolutePath:String,
@@ -32,7 +30,7 @@ data class LocalLibraryItem(
var serverAddress:String?, var serverAddress:String?,
var serverUserId:String?, var serverUserId:String?,
var libraryItemId:String? var libraryItemId:String?
) : LibraryItemWrapper() { ) : LibraryItemWrapper(id) {
@get:JsonIgnore @get:JsonIgnore
val title get() = media.metadata.title val title get() = media.metadata.title
@get:JsonIgnore @get:JsonIgnore
@@ -98,16 +96,38 @@ data class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat { override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
val coverUri = getCoverUri() val coverUri = getCoverUri()
return MediaMetadataCompat.Builder().apply { val extras = Bundle()
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) if (progress != null) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) if (progress.isFinished) {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) extras.putInt(
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) )
}.build() } else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else if (mediaType != "podcast") {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
return MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(authorName)
.setExtras(extras)
.build()
} }
} }
@@ -2,12 +2,8 @@ package com.audiobookshelf.app.media
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
@@ -26,11 +22,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
var selectedLibraryItems = mutableListOf<LibraryItem>() var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = "" var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
var selectedPodcast:Podcast? = null var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null var selectedLibraryItemId:String? = null
var serverPodcastEpisodes = listOf<PodcastEpisode>() var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>() var serverLibraryCategories = listOf<LibraryCategory>()
var serverItemsInProgress = listOf<ItemInProgress>()
var serverLibraries = listOf<Library>() var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null var serverConfigIdUsed:String? = null
var serverConfigLastPing:Long = 0L var serverConfigLastPing:Long = 0L
@@ -71,7 +67,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig() val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) { if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
serverPodcastEpisodes = listOf() podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraryCategories = listOf() serverLibraryCategories = listOf()
serverLibraries = listOf() serverLibraries = listOf()
serverLibraryItems = mutableListOf() serverLibraryItems = mutableListOf()
@@ -80,13 +76,13 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) { if (serverItemsInProgress.isNotEmpty()) {
cb(serverLibraryCategories) cb(serverItemsInProgress)
} else { } else {
apiHandler.getLibraryCategories(libraryId) { apiHandler.getAllItemsInProgress { itemsInProgress ->
serverLibraryCategories = it serverItemsInProgress = itemsInProgress
cb(it) cb(serverItemsInProgress)
} }
} }
} }
@@ -129,44 +125,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
loadLibraryItem(libraryItemId) { libraryItemWrapper -> loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper") Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
selectedLibraryItemWrapper = libraryItemWrapper
libraryItemWrapper?.let { libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf()) cb(mutableListOf())
} else { } else {
val podcast = libraryItemWrapper.media as Podcast val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode -> val children = podcast.episodes?.map { podcastEpisode ->
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}") Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}") val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress) val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
} }
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes } else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf()) cb(mutableListOf())
} else { } else {
val podcast = libraryItemWrapper.media as Podcast val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf() podcast.episodes?.forEach { podcastEpisode ->
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
}
selectedLibraryItemId = libraryItemWrapper.id selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode -> val children = podcast.episodes?.map { podcastEpisode ->
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id } val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress) val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
@@ -275,26 +266,8 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
// TODO: Load currently listening category for local items fun loadAndroidAutoItems(cb: () -> Unit) {
fun loadLocalCategory():List<LibraryCategory> {
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val cats = mutableListOf<LibraryCategory>()
if (localBooks.isNotEmpty()) {
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
}
if (localPodcasts.isNotEmpty()) {
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
}
return cats
}
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
Log.d(tag, "Load android auto items") Log.d(tag, "Load android auto items")
val cats = mutableListOf<LibraryCategory>()
val localCategories = loadLocalCategory()
cats.addAll(localCategories)
// Check if any valid server connection if not use locally downloaded books // Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected -> checkSetValidServerConnectionConfig { isConnected ->
@@ -304,40 +277,29 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
loadLibraries { libraries -> loadLibraries { libraries ->
if (libraries.isEmpty()) { if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request") Log.w(tag, "No libraries returned from server request")
cb(cats) // Return download category only cb()
} else { } else {
val library = libraries[0] val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(library.id) { libraryCategories -> loadItemsInProgressForAllLibraries { itemsInProgress ->
itemsInProgress.forEach {
// Only using book or podcast library categories for now val libraryItem = it.libraryItemWrapper as LibraryItem
libraryCategories.forEach { if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
// Add items in continue listening to serverLibraryItems
if (it.id == "continue-listening") {
it.entities.forEach { libraryItemWrapper ->
val libraryItem = libraryItemWrapper as LibraryItem
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
} }
if (it.episode != null) {
// Log.d(tag, "Found library category ${it.label} with type ${it.type}") podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
if (it.type == library.mediaType) {
// Log.d(tag, "Using library category ${it.id}")
cats.add(it)
} }
} }
cb(cats) cb() // Fully loaded
} }
} }
} }
} else { // Not connected/no internet sent downloaded cats only } else { // Not connected to server
cb(cats) cb()
} }
} }
} }
@@ -355,12 +317,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
if (id.startsWith("local")) { if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id) return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else { } else {
val podcastEpisode = serverPodcastEpisodes.find { it.id == id } return podcastEpisodeLibraryItemMap[id]
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
} else {
null
}
} }
} }
@@ -394,41 +351,6 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun getMediaDescriptionFromMediaMetadata(item: MediaMetadataCompat, progress:MediaProgressWrapper?): MediaDescriptionCompat {
val extras = Bundle()
if (progress != null) {
Log.d(tag, "Has media progress for ${item.description.title} | ${progress}")
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else {
Log.d(tag, "No media progress for ${item.description.title} | ${item.description.mediaId}")
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
return MediaDescriptionCompat.Builder()
.setMediaId(item.description.mediaId)
.setTitle(item.description.title)
.setIconUri(item.description.iconUri)
.setSubtitle(item.description.subtitle)
.setExtras(extras).build()
}
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
val lhsLength = lhs.length + 1 val lhsLength = lhs.length + 1
val rhsLength = rhs.length + 1 val rhsLength = rhs.length + 1
@@ -10,7 +10,7 @@ import com.audiobookshelf.app.data.*
class BrowseTree( class BrowseTree(
val context: Context, val context: Context,
libraryCategories: List<LibraryCategory>, itemsInProgress: List<ItemInProgress>,
libraries: List<Library> libraries: List<Library>
) { ) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>() private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@@ -21,8 +21,7 @@ class BrowseTree(
* @param drawableId - drawable res id * @param drawableId - drawable res id
* @return - uri * @return - uri
*/ */
fun getUriToDrawable(context: Context, fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId) + "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId) + '/' + context.resources.getResourceTypeName(drawableId)
@@ -32,38 +31,26 @@ class BrowseTree(
init { init {
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
val continueReadingMetadata = MediaMetadataCompat.Builder().apply { val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
}.build() }.build()
val downloadsMetadata = MediaMetadataCompat.Builder().apply { val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
}.build() }.build()
val librariesMetadata = MediaMetadataCompat.Builder().apply { val librariesMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
}.build() }.build()
// Server continue Listening cat if (!itemsInProgress.isEmpty()) {
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory -> rootList += continueListeningMetadata
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
val libraryItem = liw as LibraryItem
libraryItem.getMediaMetadata()
}
if (continueListeningMediaMetadata.isNotEmpty()) {
rootList += continueReadingMetadata
}
continueListeningMediaMetadata.forEach {
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[CONTINUE_ROOT] = children
}
} }
if (libraries.isNotEmpty()) { if (libraries.isNotEmpty()) {
@@ -78,23 +65,6 @@ class BrowseTree(
} }
rootList += downloadsMetadata rootList += downloadsMetadata
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
localBooksCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
localPodcastsCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
} }
@@ -807,8 +807,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val AUTO_MEDIA_ROOT = "/" private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
private val LIBRARIES_ROOT = "__LIBRARIES__" private val LIBRARIES_ROOT = "__LIBRARIES__"
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
private val CONTINUE_ROOT = "__CONTINUE__"
private lateinit var browseTree:BrowseTree private lateinit var browseTree:BrowseTree
@@ -851,8 +852,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
Log.d(tag, "ON LOAD CHILDREN $parentMediaId") Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
val flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach() result.detach()
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
@@ -864,53 +863,67 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem -> val children = libraryItems.map { libraryItem ->
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) val mediaDescription = libraryItem.getMediaDescription(null)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else { } else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemMediaMetadata.description.mediaId } val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val description = mediaManager.getMediaDescriptionFromMediaMetadata(libraryItemMediaMetadata, progress) val description = libraryItem.getMediaDescription(progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads } else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf() val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem -> localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata() val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val progress = DeviceManager.dbManager.getLocalMediaProgress(mediaMetadata.description.mediaId ?: "") val description = localLibraryItem.getMediaDescription(progress)
val description = mediaManager.getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
localPodcasts.forEach { localLibraryItem -> localPodcasts.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata() val mediaDescription = localLibraryItem.getMediaDescription(null)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(localBrowseItems) result.sendResult(localBrowseItems)
} else { // Load categories } else if (parentMediaId == CONTINUE_ROOT) {
mediaManager.loadAndroidAutoItems { libraryCategories -> val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) mediaManager.serverItemsInProgress.forEach { itemInProgress ->
val progress: MediaProgressWrapper?
val mediaDescription:MediaDescriptionCompat
if (itemInProgress.episode != null) {
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
}
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
} else {
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
}
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
}
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
result.sendResult(localBrowseItems)
} else {
mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item -> val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag") Log.d(tag, "Loading Browser Media Item ${item.description.title}")
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
if (flag == MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == item.description.mediaId }
val description = mediaManager.getMediaDescriptionFromMediaMetadata(item, progress)
MediaBrowserCompat.MediaItem(description, flag)
} else {
MediaBrowserCompat.MediaItem(item.description, flag)
}
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -919,10 +932,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach() result.detach()
mediaManager.loadAndroidAutoItems() { libraryCategories -> mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[ALL_ROOT]?.map { item -> val children = browseTree[LIBRARIES_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -189,21 +189,16 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized", null, null) { getRequest("/api/me/items-in-progress", null, null) {
val items = mutableListOf<LibraryCategory>() val items = mutableListOf<ItemInProgress>()
if (it.has("value")) { if (it.has("libraryItems")) {
val array = it.getJSONArray("value") val array = it.getJSONArray("libraryItems")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject val jsobj = array.get(i) as JSONObject
val type = jsobj.get("type").toString() val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
// Only support for podcast and book in android auto items.add(itemInProgress)
if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false)
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
items.add(item)
}
} }
} }
cb(items) cb(items)
+73 -30
View File
@@ -8,9 +8,7 @@
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span> <span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div> </div>
<div class="top-4 right-4 absolute cursor-pointer"> <div class="top-4 right-4 absolute cursor-pointer">
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction"> <span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
<span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu>
</div> </div>
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p> <p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
</div> </div>
@@ -66,23 +64,24 @@
<div id="playerControls" class="absolute right-0 bottom-0 py-2"> <div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span> <span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span> <span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" /> <widgets-spinner-icon v-else class="h-8 w-8" />
</div> </div>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span> <span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div> </div>
</div> </div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3"> <div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack"> <div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" /> <div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" /> <div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" /> <div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
</div> </div>
<div id="timestamp-row" class="flex pt-0.5"> <div id="timestamp-row" class="flex pt-0.5">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
@@ -95,6 +94,24 @@
</div> </div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
<template v-slot:chapter_track="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
<template v-slot:lock="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
</modals-dialog>
</div> </div>
</template> </template>
@@ -139,10 +156,12 @@ export default {
touchStartTime: 0, touchStartTime: 0,
touchEndY: 0, touchEndY: 0,
useChapterTrack: false, useChapterTrack: false,
lockUi: false,
isLoading: false, isLoading: false,
touchTrackStart: false, touchTrackStart: false,
dragPercent: 0, dragPercent: 0,
syncStatus: 0 syncStatus: 0,
showMoreMenuDialog: false
} }
}, },
watch: { watch: {
@@ -153,17 +172,24 @@ export default {
}, },
computed: { computed: {
menuItems() { menuItems() {
var items = [] var items = [
items.push({ {
text: 'Chapter Track', text: 'Chapter Track',
value: 'chapter_track', value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank' icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
}) },
items.push({ {
text: 'Close Player', text: this.lockUi ? 'Unlock Player' : 'Lock Player',
value: 'close', value: 'lock',
icon: 'close' icon: this.lockUi ? 'lock' : 'lock_open'
}) },
{
text: 'Close Player',
value: 'close',
icon: 'close'
}
]
return items return items
}, },
jumpForwardIcon() { jumpForwardIcon() {
@@ -310,7 +336,7 @@ export default {
} }
}, },
touchstartTrack(e) { touchstartTrack(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true this.touchTrackStart = true
}, },
selectChapter(chapter) { selectChapter(chapter) {
@@ -460,6 +486,10 @@ export default {
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px' this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
}
if (this.useChapterTrack) { if (this.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px' if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px' if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
@@ -487,7 +517,7 @@ export default {
} }
}, },
clickTrack(e) { clickTrack(e) {
if (this.isLoading) return if (this.isLoading || this.lockUi) return
if (!this.showFullscreen) { if (!this.showFullscreen) {
// Track not clickable on mini-player // Track not clickable on mini-player
return return
@@ -612,18 +642,22 @@ export default {
} }
}, },
clickMenuAction(action) { clickMenuAction(action) {
if (action === 'chapter_track') { this.showMoreMenuDialog = false
this.useChapterTrack = !this.useChapterTrack this.$nextTick(() => {
if (action === 'lock') {
this.lockUi = !this.lockUi
this.$localStore.setPlayerLock(this.lockUi)
} else if (action === 'chapter_track') {
this.useChapterTrack = !this.useChapterTrack
this.$nextTick(() => {
this.updateTimestamp() this.updateTimestamp()
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
}) this.$localStore.setUseChapterTrack(this.useChapterTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack) } else if (action === 'close') {
} else if (action === 'close') { this.closePlayback()
this.closePlayback() }
} })
}, },
forceCloseDropdownMenu() { forceCloseDropdownMenu() {
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) { if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
@@ -705,6 +739,7 @@ export default {
}, },
async init() { async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack() this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.lockUi = await this.$localStore.getPlayerLock()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
@@ -737,8 +772,12 @@ export default {
mounted() { mounted() {
this.updateScreenSize() this.updateScreenSize()
if (screen.orientation) { if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange) screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
} }
window.addEventListener('resize', this.screenOrientationChange)
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt) this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
@@ -748,8 +787,12 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
if (screen.orientation) { if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange) screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
} }
window.removeEventListener('resize', this.screenOrientationChange)
if (this.playbackSession) { if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback') console.log('[AudioPlayer] Before destroy closing playback')
+7 -5
View File
@@ -10,11 +10,13 @@
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)"> <slot :name="item.value" :item="item" :selected="item.value === selected">
<div class="relative flex items-center px-3"> <li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <div class="relative flex items-center px-3">
</div> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</li> </div>
</li>
</slot>
</template> </template>
</ul> </ul>
</div> </div>
+2 -1
View File
@@ -50,8 +50,9 @@ export default {
methods: { methods: {
async clickedOption(lib) { async clickedOption(lib) {
this.show = false this.show = false
if (lib.id === this.currentLibraryId) return
await this.$store.dispatch('libraries/fetch', lib.id) await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed') this.$eventBus.$emit('library-changed', lib.id)
this.$localStore.setLastLibraryId(lib.id) this.$localStore.setLastLibraryId(lib.id)
} }
}, },
+4
View File
@@ -39,6 +39,10 @@ export default {
text: 'Author (Last, First)', text: 'Author (Last, First)',
value: 'media.metadata.authorNameLF' value: 'media.metadata.authorNameLF'
}, },
{
text: 'Published Year',
value: 'media.metadata.publishedYear'
},
{ {
text: 'Added At', text: 'Added At',
value: 'addedAt' value: 'addedAt'
@@ -1,17 +1,17 @@
<template> <template>
<div class="w-full px-2 py-2 overflow-hidden relative"> <div class="w-full px-2 py-2 overflow-hidden relative">
<div v-if="book" class="flex w-full"> <nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
<div class="h-full relative" :style="{ width: bookWidth + 'px' }"> <div class="h-full relative" :style="{ width: bookWidth + 'px' }">
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div class="flex-grow book-table-content h-full px-2 flex items-center"> <div class="flex-grow book-table-content h-full px-2 flex items-center">
<div class="max-w-full"> <div class="max-w-full">
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link> <p class="truncate block text-sm">{{ bookTitle }}</p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p> <p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
<p class="text-xxs text-gray-500">{{ bookDuration }}</p> <p class="text-xxs text-gray-500">{{ bookDuration }}</p>
</div> </div>
</div> </div>
</div> </nuxt-link>
</div> </div>
</template> </template>
+8 -4
View File
@@ -58,6 +58,7 @@
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; }; E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; };
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; }; E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; };
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; }; E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; };
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */; };
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; }; E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -117,6 +118,7 @@
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = "<group>"; }; E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = "<group>"; };
E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = "<group>"; }; E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = "<group>"; };
E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; }; E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; };
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSleepTimer.swift; sourceTree = "<group>"; };
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; }; E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -145,6 +147,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */, 3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
3ABF618E2804325C0070250E /* PlayerHandler.swift */, 3ABF618E2804325C0070250E /* PlayerHandler.swift */,
E9E985F728B02D9400957F23 /* PlayerProgress.swift */, E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
); );
@@ -438,6 +441,7 @@
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */, E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */, E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */, E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */,
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */, E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */, E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */, 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
@@ -595,12 +599,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13; CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.56; MARKETING_VERSION = 0.9.58;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -619,12 +623,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13; CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.56; MARKETING_VERSION = 0.9.58;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+16 -1
View File
@@ -6,12 +6,13 @@ import RealmSwift
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow? var window: UIWindow?
var backgroundCompletionHandler: (() -> Void)?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch. // Override point for customization after application launch.
let configuration = Realm.Configuration( let configuration = Realm.Configuration(
schemaVersion: 2, schemaVersion: 4,
migrationBlock: { migration, oldSchemaVersion in migrationBlock: { migration, oldSchemaVersion in
if (oldSchemaVersion < 1) { if (oldSchemaVersion < 1) {
NSLog("Realm schema version was \(oldSchemaVersion)") NSLog("Realm schema version was \(oldSchemaVersion)")
@@ -19,6 +20,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
newObject?["enableAltView"] = false newObject?["enableAltView"] = false
} }
} }
if (oldSchemaVersion < 4) {
NSLog("Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
var indexCounter = 1
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
newObject?["index"] = indexCounter
indexCounter += 1
}
}
} }
) )
Realm.Configuration.defaultConfiguration = configuration Realm.Configuration.defaultConfiguration = configuration
@@ -64,6 +73,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// tracking app url opens, make sure to keep this call // tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
} }
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
// Stores the completion handler for background downloads
// The identifier of this method can be ignored at this time as we only have one background url session
backgroundCompletionHandler = completionHandler
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event) super.touchesBegan(touches, with: event)
+35 -34
View File
@@ -38,9 +38,11 @@ public class AbsAudioPlayer: CAPPlugin {
do { do {
// Fetch the most recent active session // Fetch the most recent active session
let activeSession = try await Realm().objects(PlaybackSession.self).where({ $0.isActiveSession == true }).last?.freeze() let activeSession = try await Realm().objects(PlaybackSession.self).where({
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
}).last?.freeze()
if let activeSession = activeSession { if let activeSession = activeSession {
await PlayerProgress.syncFromServer() await PlayerProgress.shared.syncFromServer()
try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate) try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate)
} }
} catch { } catch {
@@ -79,9 +81,9 @@ public class AbsAudioPlayer: CAPPlugin {
NSLog("Failed to get local playback session") NSLog("Failed to get local playback session")
return call.resolve([:]) return call.resolve([:])
} }
playbackSession.save()
do { do {
try playbackSession.save()
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate) try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try playbackSession.asDictionary()) call.resolve(try playbackSession.asDictionary())
} catch(let exception) { } catch(let exception) {
@@ -91,8 +93,8 @@ public class AbsAudioPlayer: CAPPlugin {
} }
} else { // Playing from the server } else { // Playing from the server
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
session.save()
do { do {
try session.save()
try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate) try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try session.asDictionary()) call.resolve(try session.asDictionary())
} catch(let exception) { } catch(let exception) {
@@ -120,7 +122,7 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func setPlaybackSpeed(_ call: CAPPluginCall) { @objc func setPlaybackSpeed(_ call: CAPPluginCall) {
let playbackRate = call.getFloat("value", 1.0) let playbackRate = call.getFloat("value", 1.0)
let settings = PlayerSettings.main() let settings = PlayerSettings.main()
settings.update { try? settings.update {
settings.playbackRate = playbackRate settings.playbackRate = playbackRate
} }
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate) PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
@@ -166,48 +168,49 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func decreaseSleepTime(_ call: CAPPluginCall) { @objc func decreaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000) let seconds = time/1000
PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds)
call.resolve() call.resolve()
} }
@objc func increaseSleepTime(_ call: CAPPluginCall) { @objc func increaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000) let seconds = time/1000
PlayerHandler.increaseSleepTime(increaseSeconds: seconds)
call.resolve() call.resolve()
} }
@objc func setSleepTimer(_ call: CAPPluginCall) { @objc func setSleepTimer(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
let timeSeconds = time / 1000 let isChapterTime = call.getBool("isChapterTime", false)
NSLog("chapter time: \(call.getBool("isChapterTime", false))") let seconds = time / 1000
if call.getBool("isChapterTime", false) { NSLog("chapter time: \(isChapterTime)")
let timeToPause = timeSeconds - Int(PlayerHandler.getCurrentTime() ?? 0) if isChapterTime {
if timeToPause < 0 { return call.resolve([ "success": false ]) } PlayerHandler.setChapterSleepTime(stopAt: seconds)
PlayerHandler.sleepTimerChapterStopTime = timeSeconds
PlayerHandler.remainingSleepTime = timeToPause
return call.resolve([ "success": true ]) return call.resolve([ "success": true ])
} else {
PlayerHandler.setSleepTime(secondsUntilSleep: seconds)
call.resolve([ "success": true ])
} }
PlayerHandler.sleepTimerChapterStopTime = nil
PlayerHandler.remainingSleepTime = timeSeconds
call.resolve([ "success": true ])
} }
@objc func cancelSleepTimer(_ call: CAPPluginCall) { @objc func cancelSleepTimer(_ call: CAPPluginCall) {
PlayerHandler.remainingSleepTime = nil PlayerHandler.cancelSleepTime()
PlayerHandler.sleepTimerChapterStopTime = nil
call.resolve() call.resolve()
} }
@objc func getSleepTimerTime(_ call: CAPPluginCall) { @objc func getSleepTimerTime(_ call: CAPPluginCall) {
call.resolve([ call.resolve([
"value": PlayerHandler.remainingSleepTime "value": PlayerHandler.getSleepTimeRemaining()
]) ])
} }
@@ -219,7 +222,7 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func sendSleepTimerSet() { @objc func sendSleepTimerSet() {
self.notifyListeners("onSleepTimerSet", data: [ self.notifyListeners("onSleepTimerSet", data: [
"value": PlayerHandler.remainingSleepTime "value": PlayerHandler.getSleepTimeRemaining()
]) ])
} }
@@ -240,17 +243,15 @@ public class AbsAudioPlayer: CAPPlugin {
// If direct playing then fallback to transcode // If direct playing then fallback to transcode
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
session.save()
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate)
do { do {
try session.save()
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate)
self.sendPlaybackSession(session: try session.asDictionary()) self.sendPlaybackSession(session: try session.asDictionary())
self.sendMetadata()
} catch(let exception) { } catch(let exception) {
NSLog("failed to convert session to json") NSLog("Failed to start transcoded session")
debugPrint(exception) debugPrint(exception)
} }
self.sendMetadata()
} }
} else { } else {
self.notifyListeners("onPlaybackFailed", data: [ self.notifyListeners("onPlaybackFailed", data: [
+34 -28
View File
@@ -43,7 +43,7 @@ public class AbsDatabase: CAPPlugin {
let config = ServerConnectionConfig() let config = ServerConnectionConfig()
config.id = id ?? "" config.id = id ?? ""
config.index = 1 config.index = 0
config.name = name config.name = name
config.address = address config.address = address
config.userId = userId config.userId = userId
@@ -51,7 +51,8 @@ public class AbsDatabase: CAPPlugin {
config.token = token config.token = token
Store.serverConfig = config Store.serverConfig = config
call.resolve(convertServerConnectionConfigToJSON(config: config)) let savedConfig = Store.serverConfig // Fetch the latest value
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
} }
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) { @objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
let id = call.getString("serverConnectionConfigId", "") let id = call.getString("serverConnectionConfigId", "")
@@ -139,7 +140,7 @@ public class AbsDatabase: CAPPlugin {
call.reject("localMediaProgressId not specificed") call.reject("localMediaProgressId not specificed")
return return
} }
Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId) try? Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
call.resolve() call.resolve()
} }
@@ -171,15 +172,15 @@ public class AbsDatabase: CAPPlugin {
return call.reject("localLibraryItemId or localMediaProgressId must be specified") return call.reject("localLibraryItemId or localMediaProgressId must be specified")
} }
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
guard let localMediaProgress = localMediaProgress else { guard let localMediaProgress = localMediaProgress else {
call.reject("Local media progress not found or created") call.reject("Local media progress not found or created")
return return
} }
localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress") NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
Database.shared.saveLocalMediaProgress(localMediaProgress) try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
call.resolve(try localMediaProgress.asDictionary()) call.resolve(try localMediaProgress.asDictionary())
} catch { } catch {
call.reject("Failed to sync media progress") call.reject("Failed to sync media progress")
@@ -195,31 +196,36 @@ public class AbsDatabase: CAPPlugin {
NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)") NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)")
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) do {
guard let localMediaProgress = localMediaProgress else { let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
call.resolve(["error": "Library Item not found"]) guard let localMediaProgress = localMediaProgress else {
return call.resolve(["error": "Library Item not found"])
} return
}
// Update finished status // Update finished status
localMediaProgress.updateIsFinished(isFinished) try localMediaProgress.updateIsFinished(isFinished)
Database.shared.saveLocalMediaProgress(localMediaProgress)
// Build API response
// Build API response let progressDictionary = try? localMediaProgress.asDictionary()
let progressDictionary = try? localMediaProgress.asDictionary() var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
// Send update to the server if logged in
// Send update to the server if logged in let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil
let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId
let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId if hasLinkedServer && loggedIntoServer {
if hasLinkedServer && loggedIntoServer { response["server"] = true
response["server"] = true let payload = ["isFinished": isFinished]
let payload = ["isFinished": isFinished] ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) {
ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) { call.resolve(response)
}
} else {
call.resolve(response) call.resolve(response)
} }
} else { } catch {
call.resolve(response) debugPrint(error)
call.resolve(["error": "Failed to mark as complete"])
return
} }
} }
+19 -6
View File
@@ -15,9 +15,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
private lazy var session: URLSession = { private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
let queue = OperationQueue() let queue = OperationQueue()
queue.maxConcurrentOperationCount = 5 queue.maxConcurrentOperationCount = 5
return URLSession(configuration: .default, delegate: self, delegateQueue: queue) return URLSession(configuration: config, delegate: self, delegateQueue: queue)
}() }()
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent) private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
private var downloadItemProgress = [String: DownloadItem]() private var downloadItemProgress = [String: DownloadItem]()
@@ -28,7 +29,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
let realm = try! Realm() let realm = try Realm()
try realm.write { try realm.write {
downloadItemPart.progress = 100 downloadItemPart.progress = 100
downloadItemPart.completed = true downloadItemPart.completed = true
@@ -78,6 +79,18 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
} }
} }
// Called when downloads are complete on the background thread
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let backgroundCompletionHandler =
appDelegate.backgroundCompletionHandler else {
return
}
backgroundCompletionHandler()
}
}
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) { private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
do { do {
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription } guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
@@ -139,7 +152,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
} }
self.handleDownloadTaskCompleteFromDownloadItem(item) self.handleDownloadTaskCompleteFromDownloadItem(item)
if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) { if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) {
item.delete() try? item.delete()
} }
} }
@@ -181,7 +194,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
} }
} else { } else {
localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile)
Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!) try? Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!)
} }
statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary()
@@ -189,7 +202,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
if let progress = libraryItem.userMediaProgress { if let progress = libraryItem.userMediaProgress {
let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId }) let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId })
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress) let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress)
Database.shared.saveLocalMediaProgress(localMediaProgress) try? localMediaProgress.save()
statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary()
} }
@@ -276,7 +289,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
} }
// Persist in the database before status start coming in // Persist in the database before status start coming in
Database.shared.saveDownloadItem(downloadItem) try Database.shared.saveDownloadItem(downloadItem)
// Start all the downloads // Start all the downloads
for task in tasks { for task in tasks {
+20 -15
View File
@@ -70,7 +70,7 @@ public class AbsFileSystem: CAPPlugin {
do { do {
if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
try FileManager.default.removeItem(at: item.contentDirectory!) try FileManager.default.removeItem(at: item.contentDirectory!)
item.delete() try item.delete()
success = true success = true
} }
} catch { } catch {
@@ -89,24 +89,29 @@ public class AbsFileSystem: CAPPlugin {
var success = false var success = false
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
item.update { do {
do { try item.update {
if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) { do {
try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath) if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) {
item.realm?.delete(item.localFiles[fileIndex]) try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath)
if item.isPodcast, let media = item.media { item.realm?.delete(item.localFiles[fileIndex])
if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) { if item.isPodcast, let media = item.media {
media.episodes.remove(at: episodeIndex) if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) {
media.episodes.remove(at: episodeIndex)
}
item.media = media
} }
item.media = media call.resolve(try item.asDictionary())
success = true
} }
call.resolve(try item.asDictionary()) } catch {
success = true NSLog("Failed to delete \(error)")
success = false
} }
} catch {
NSLog("Failed to delete \(error)")
success = false
} }
} catch {
NSLog("Failed to delete \(error)")
success = false
} }
} }
+6 -6
View File
@@ -9,12 +9,12 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '..\..\node_modules\@capacitor\app' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorDialog', :path => '..\..\node_modules\@capacitor\dialog' pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
pod 'CapacitorHaptics', :path => '..\..\node_modules\@capacitor\haptics' pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network' pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage' pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
end end
target 'App' do target 'App' do
@@ -31,6 +31,7 @@ class PlaybackSession: Object, Codable, Deletable {
@Persisted var serverConnectionConfigId: String? @Persisted var serverConnectionConfigId: String?
@Persisted var serverAddress: String? @Persisted var serverAddress: String?
@Persisted var isActiveSession = true @Persisted var isActiveSession = true
@Persisted var serverUpdatedAt: Double = 0
var isLocal: Bool { self.localLibraryItem != nil } var isLocal: Bool { self.localLibraryItem != nil }
var mediaPlayer: String { "AVPlayer" } var mediaPlayer: String { "AVPlayer" }
@@ -88,8 +88,8 @@ extension DownloadItem {
self.downloadItemParts.allSatisfy({ $0.failed == false }) self.downloadItemParts.allSatisfy({ $0.failed == false })
} }
func delete() { func delete() throws {
try! self.realm?.write { try self.realm?.write {
self.realm?.delete(self.downloadItemParts) self.realm?.delete(self.downloadItemParts)
self.realm?.delete(self) self.realm?.delete(self)
} }
@@ -198,8 +198,8 @@ extension LocalLibraryItem {
) )
} }
func delete() { func delete() throws {
try! self.realm?.write { try self.realm?.write {
self.realm?.delete(self.localFiles) self.realm?.delete(self.localFiles)
self.realm?.delete(self) self.realm?.delete(self)
} }
@@ -63,8 +63,12 @@ class LocalMediaProgress: Object, Codable {
try container.encode(localLibraryItemId, forKey: .localLibraryItemId) try container.encode(localLibraryItemId, forKey: .localLibraryItemId)
try container.encode(localEpisodeId, forKey: .localEpisodeId) try container.encode(localEpisodeId, forKey: .localEpisodeId)
try container.encode(duration, forKey: .duration) try container.encode(duration, forKey: .duration)
try container.encode(progress, forKey: .progress) if progress.isNaN == false {
try container.encode(currentTime, forKey: .currentTime) try container.encode(progress, forKey: .progress)
}
if currentTime.isNaN == false {
try container.encode(currentTime, forKey: .currentTime)
}
try container.encode(isFinished, forKey: .isFinished) try container.encode(isFinished, forKey: .isFinished)
try container.encode(lastUpdate, forKey: .lastUpdate) try container.encode(lastUpdate, forKey: .lastUpdate)
try container.encode(startedAt, forKey: .startedAt) try container.encode(startedAt, forKey: .startedAt)
@@ -115,8 +119,8 @@ extension LocalMediaProgress {
self.finishedAt = progress.finishedAt self.finishedAt = progress.finishedAt
} }
func updateIsFinished(_ finished: Bool) { func updateIsFinished(_ finished: Bool) throws {
try! Realm().write { try self.realm?.write {
if self.isFinished != finished { if self.isFinished != finished {
self.progress = finished ? 1.0 : 0.0 self.progress = finished ? 1.0 : 0.0
} }
@@ -131,8 +135,8 @@ extension LocalMediaProgress {
} }
} }
func updateFromPlaybackSession(_ playbackSession: PlaybackSession) { func updateFromPlaybackSession(_ playbackSession: PlaybackSession) throws {
try! Realm().write { try self.realm?.write {
self.currentTime = playbackSession.currentTime self.currentTime = playbackSession.currentTime
self.progress = playbackSession.progress self.progress = playbackSession.progress
self.lastUpdate = Date().timeIntervalSince1970 * 1000 self.lastUpdate = Date().timeIntervalSince1970 * 1000
@@ -141,8 +145,8 @@ extension LocalMediaProgress {
} }
} }
func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) { func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws {
try! Realm().write { try self.realm?.write {
self.isFinished = serverMediaProgress.isFinished self.isFinished = serverMediaProgress.isFinished
self.progress = serverMediaProgress.progress self.progress = serverMediaProgress.progress
self.currentTime = serverMediaProgress.currentTime self.currentTime = serverMediaProgress.currentTime
@@ -153,21 +157,38 @@ extension LocalMediaProgress {
} }
} }
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) -> LocalMediaProgress? { static func getLocalMediaProgressId(localLibraryItemId: String?, localEpisodeId: String?) -> String? {
if let localMediaProgressId = localMediaProgressId { if let itemId = localLibraryItemId, let episodeId = localEpisodeId {
// Check if it existing in the database, if not, we need to create it return "\(itemId)-\(episodeId)"
if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) { } else if let itemId = localLibraryItemId {
return progress return itemId
}
}
if let localLibraryItemId = localLibraryItemId {
guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil }
let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId)
return LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode)
} else { } else {
return nil return nil
} }
} }
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) throws -> LocalMediaProgress? {
let localMediaProgressId = localMediaProgressId != nil ? localMediaProgressId : LocalMediaProgress.getLocalMediaProgressId(localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
let realm = try Realm()
return try realm.write { () -> LocalMediaProgress? in
if let localMediaProgressId = localMediaProgressId {
// Check if it existing in the database, if not, we need to create it
if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) {
return progress
}
}
if let localLibraryItemId = localLibraryItemId {
guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil }
let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId)
let progress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode)
realm.add(progress)
return progress
} else {
return nil
}
}
}
} }
@@ -19,6 +19,13 @@ class AudioTrack: EmbeddedObject, Codable {
@Persisted var localFileId: String? @Persisted var localFileId: String?
@Persisted var serverIndex: Int? @Persisted var serverIndex: Int?
var endOffset: Double? {
if let startOffset = startOffset {
return startOffset + duration
}
return nil
}
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex
} }
@@ -37,7 +44,7 @@ class AudioTrack: EmbeddedObject, Codable {
contentUrl = try? values.decode(String.self, forKey: .contentUrl) contentUrl = try? values.decode(String.self, forKey: .contentUrl)
mimeType = try values.decode(String.self, forKey: .mimeType) mimeType = try values.decode(String.self, forKey: .mimeType)
metadata = try? values.decode(FileMetadata.self, forKey: .metadata) metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
localFileId = try! values.decodeIfPresent(String.self, forKey: .localFileId) localFileId = try? values.decodeIfPresent(String.self, forKey: .localFileId)
serverIndex = try? values.decode(Int.self, forKey: .serverIndex) serverIndex = try? values.decode(Int.self, forKey: .serverIndex)
} }
+343 -115
View File
@@ -18,12 +18,13 @@ enum PlayMethod:Int {
} }
class AudioPlayer: NSObject { class AudioPlayer: NSObject {
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
// enums and @objc are not compatible // enums and @objc are not compatible
@objc dynamic var status: Int @objc dynamic var status: Int
@objc dynamic var rate: Float @objc dynamic var rate: Float
private var tmpRate: Float = 1.0 private var tmpRate: Float = 1.0
private var lastPlayTime: Double = 0.0
private var playerContext = 0 private var playerContext = 0
private var playerItemContext = 0 private var playerItemContext = 0
@@ -31,15 +32,24 @@ class AudioPlayer: NSObject {
private var playWhenReady: Bool private var playWhenReady: Bool
private var initialPlaybackRate: Float private var initialPlaybackRate: Float
private var audioPlayer: AVQueuePlayer internal var audioPlayer: AVQueuePlayer
private var sessionId: String private var sessionId: String
private var timeObserverToken: Any?
private var queueObserver:NSKeyValueObservation? private var queueObserver:NSKeyValueObservation?
private var queueItemStatusObserver:NSKeyValueObservation? private var queueItemStatusObserver:NSKeyValueObservation?
private var currentTrackIndex = 0 // Sleep timer values
internal var sleepTimeChapterStopAt: Double?
internal var sleepTimeChapterToken: Any?
internal var sleepTimer: Timer?
internal var sleepTimeRemaining: Double?
internal var currentTrackIndex = 0
private var allPlayerItems:[AVPlayerItem] = [] private var allPlayerItems:[AVPlayerItem] = []
private var pausedTimer: Timer?
// MARK: - Constructor // MARK: - Constructor
init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) { init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) {
self.playWhenReady = playWhenReady self.playWhenReady = playWhenReady
@@ -56,15 +66,23 @@ class AudioPlayer: NSObject {
initAudioSession() initAudioSession()
setupRemoteTransportControls() setupRemoteTransportControls()
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! let playbackSession = self.getPlaybackSession()
guard let playbackSession = playbackSession else {
NSLog("Failed to fetch playback session. Player will not initialize")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return
}
// Listen to player events // Listen to player events
self.setupAudioSessionNotifications()
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
for track in playbackSession.audioTracks { for track in playbackSession.audioTracks {
let playerItem = AVPlayerItem(asset: createAsset(itemId: playbackSession.libraryItemId!, track: track)) if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) {
self.allPlayerItems.append(playerItem) let playerItem = AVPlayerItem(asset: playerAsset)
self.allPlayerItems.append(playerItem)
}
} }
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime) self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
@@ -77,22 +95,27 @@ class AudioPlayer: NSObject {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last) self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
} }
setupTimeObserver()
setupQueueObserver() setupQueueObserver()
setupQueueItemStatusObserver() setupQueueItemStatusObserver()
NSLog("Audioplayer ready") NSLog("Audioplayer ready")
} }
deinit { deinit {
self.stopPausedTimer()
self.removeSleepTimer()
self.removeTimeObserver()
self.queueObserver?.invalidate() self.queueObserver?.invalidate()
self.queueItemStatusObserver?.invalidate() self.queueItemStatusObserver?.invalidate()
destroy()
} }
public func destroy() { public func destroy() {
// Pause is not synchronous causing this error on below lines: // Pause is not synchronous causing this error on below lines:
// AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session // AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session
// It is related to L79 `AVAudioSession.sharedInstance().setActive(false)` // It is related to L79 `AVAudioSession.sharedInstance().setActive(false)`
pause() self.pause()
audioPlayer.replaceCurrentItem(with: nil) self.audioPlayer.replaceCurrentItem(with: nil)
do { do {
try AVAudioSession.sharedInstance().setActive(false) try AVAudioSession.sharedInstance().setActive(false)
@@ -101,18 +124,28 @@ class AudioPlayer: NSObject {
print(error) print(error)
} }
self.removeAudioSessionNotifications()
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
UIApplication.shared.endReceivingRemoteControlEvents() UIApplication.shared.endReceivingRemoteControlEvents()
} }
// Remove observers
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext)
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
} }
func isInitialized() -> Bool { public func isInitialized() -> Bool {
return self.status != -1 return self.status != -1
} }
func getItemIndexForTime(time:Double) -> Int { public func getPlaybackSession() -> PlaybackSession? {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! return Database.shared.getPlaybackSession(id: self.sessionId)
}
private func getItemIndexForTime(time:Double) -> Int {
guard let playbackSession = self.getPlaybackSession() else { return 0 }
for index in 0..<self.allPlayerItems.count { for index in 0..<self.allPlayerItems.count {
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0 let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
let duration = playbackSession.audioTracks[index].duration let duration = playbackSession.audioTracks[index].duration
@@ -124,8 +157,59 @@ class AudioPlayer: NSObject {
return 0 return 0
} }
func setupQueueObserver() { private func setupAudioSessionNotifications() {
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in NotificationCenter.default.addObserver(self, selector: #selector(handleInteruption), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
NotificationCenter.default.addObserver(self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
}
private func removeAudioSessionNotifications() {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
}
private func setupTimeObserver() {
// Time observer should be configured on the main queue
DispatchQueue.runOnMainQueue {
self.removeTimeObserver()
let timeScale = CMTimeScale(NSEC_PER_SEC)
// Rate will be different depending on playback speed, aim for 2 observations/sec
let seconds = 0.5 * (self.rate > 0 ? self.rate : 1.0)
let time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
guard let self = self else { return }
guard let currentTime = self.getCurrentTime() else { return }
let isPlaying = self.isPlaying()
Task {
// Let the player update the current playback positions
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: isPlaying, isStopping: false)
}
if self.isSleepTimerSet() {
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
// Handle a sitation where the user skips past the chapter end
if self.isChapterSleepTimerBeforeTime(currentTime) {
self.removeSleepTimer()
}
}
}
}
}
private func removeTimeObserver() {
if let timeObserverToken = timeObserverToken {
self.audioPlayer.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
}
private func setupQueueObserver() {
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) { [weak self] _,_ in
guard let self = self else { return }
let prevTrackIndex = self.currentTrackIndex let prevTrackIndex = self.currentTrackIndex
self.audioPlayer.currentItem.map { item in self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0 self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
@@ -136,107 +220,173 @@ class AudioPlayer: NSObject {
} }
} }
func setupQueueItemStatusObserver() { private func setupQueueItemStatusObserver() {
NSLog("queueStatusObserver: Setting up")
// Listen for player item updates
self.queueItemStatusObserver?.invalidate() self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { [weak self] playerItem, change in
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! self?.handleQueueItemStatus(playerItem: playerItem)
if (playerItem.status == .readyToPlay) {
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
self.updateNowPlaying()
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
let firstReady = self.status < 0
if firstReady || self.playWhenReady {
self.seek(playbackSession.currentTime, from: "queueItemStatusObserver")
}
// Mark the player as ready
self.status = 0
// Start the player, if requested
if self.playWhenReady {
self.playWhenReady = false
self.play()
}
} else if (playerItem.status == .failed) {
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
}) })
// Ensure we didn't miss a player item update during initialization
if let playerItem = self.audioPlayer.currentItem {
self.handleQueueItemStatus(playerItem: playerItem)
}
}
private func handleQueueItemStatus(playerItem: AVPlayerItem) {
NSLog("queueStatusObserver: Current item status changed")
guard let playbackSession = self.getPlaybackSession() else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return
}
if (playerItem.status == .readyToPlay) {
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
let firstReady = self.status < 0
if firstReady && !self.playWhenReady {
// Seek is async, and if we call this when also pressing play, we will get weird jumps in the scrub bar depending on timing
// Seeking to the correct position happens during play()
self.seek(playbackSession.currentTime, from: "queueItemStatusObserver")
}
// Mark the player as ready
self.status = 0
// Start the player, if requested
if self.playWhenReady {
self.playWhenReady = false
self.play()
}
} else if (playerItem.status == .failed) {
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
}
private func startPausedTimer() {
guard self.pausedTimer == nil else { return }
self.queue.async {
self.pausedTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { timer in
NSLog("PAUSE TIMER: Syncing from server")
Task { await PlayerProgress.shared.syncFromServer() }
}
}
}
private func stopPausedTimer() {
self.pausedTimer?.invalidate()
self.pausedTimer = nil
} }
// MARK: - Methods // MARK: - Methods
public func play(allowSeekBack: Bool = false) { public func play(allowSeekBack: Bool = false) {
guard self.isInitialized() else { return } guard self.isInitialized() else { return }
guard let session = self.getPlaybackSession() else {
if allowSeekBack { NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
let diffrence = Date.timeIntervalSinceReferenceDate - lastPlayTime return
var time: Int?
if lastPlayTime == 0 {
time = 5
} else if diffrence < 6 {
time = 2
} else if diffrence < 12 {
time = 10
} else if diffrence < 30 {
time = 15
} else if diffrence < 180 {
time = 20
} else if diffrence < 3600 {
time = 25
} else {
time = 29
}
if time != nil {
seek(getCurrentTime() - Double(time!), from: "play")
}
} }
lastPlayTime = Date.timeIntervalSinceReferenceDate
self.audioPlayer.play()
self.status = 1
self.rate = self.tmpRate
self.audioPlayer.rate = self.tmpRate
updateNowPlaying() // Determine where we are starting playback
let lastPlayed = (session.updatedAt ?? 0)/1000
let currentTime = allowSeekBack ? calculateSeekBackTimeAtCurrentTime(session.currentTime, lastPlayed: lastPlayed) : session.currentTime
// Sync our new playback position
Task { await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: false) }
// Start playback, with a seek, for as smooth a scrub bar start as possible
let currentTrackStartOffset = session.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = currentTime - currentTrackStartOffset
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] completed in
guard completed else { return }
self?.resumePlayback()
}
}
private func calculateSeekBackTimeAtCurrentTime(_ currentTime: Double, lastPlayed: Double) -> Double {
let difference = Date.timeIntervalSinceReferenceDate - lastPlayed
var time: Double = 0
// Scale seek back time based on how long since last play
if lastPlayed == 0 {
time = 5
} else if difference < 6 {
time = 2
} else if difference < 12 {
time = 10
} else if difference < 30 {
time = 15
} else if difference < 180 {
time = 20
} else if difference < 3600 {
time = 25
} else {
time = 29
}
// Wind the clock back
return currentTime - time
}
private func resumePlayback() {
NSLog("PLAY: Resuming playback")
// Stop the paused timer
self.stopPausedTimer()
self.markAudioSessionAs(active: true)
self.audioPlayer.play()
self.audioPlayer.rate = self.tmpRate
self.status = 1
// Update the progress
self.updateNowPlaying()
} }
public func pause() { public func pause() {
guard self.isInitialized() else { return } guard self.isInitialized() else { return }
NSLog("PAUSE: Pausing playback")
self.audioPlayer.pause() self.audioPlayer.pause()
self.markAudioSessionAs(active: false)
Task {
if let currentTime = self.getCurrentTime() {
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: true)
}
}
self.status = 0 self.status = 0
self.rate = 0.0
updateNowPlaying() updateNowPlaying()
lastPlayTime = Date.timeIntervalSinceReferenceDate
self.startPausedTimer()
} }
public func seek(_ to: Double, from: String) { public func seek(_ to: Double, from: String) {
let continuePlaying = rate > 0.0 let continuePlaying = rate > 0.0
pause() self.pause()
NSLog("Seek to \(to) from \(from)") NSLog("SEEK: Seek to \(to) from \(from)")
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! guard let playbackSession = self.getPlaybackSession() else { return }
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex] let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0 let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration let trackEnd = ctso + currentTrack.duration
NSLog("Seek current track END = \(trackEnd)") NSLog("SEEK: Seek current track END = \(trackEnd)")
let indexOfSeek = getItemIndexForTime(time: to) let indexOfSeek = getItemIndexForTime(time: to)
NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)") NSLog("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
// Reconstruct queue if seeking to a different track // Reconstruct queue if seeking to a different track
if (self.currentTrackIndex != indexOfSeek) { if (self.currentTrackIndex != indexOfSeek) {
self.currentTrackIndex = indexOfSeek self.currentTrackIndex = indexOfSeek
playbackSession.update { try? playbackSession.update {
playbackSession.currentTime = to playbackSession.currentTime = to
} }
@@ -251,59 +401,72 @@ class AudioPlayer: NSObject {
setupQueueItemStatusObserver() setupQueueItemStatusObserver()
} else { } else {
NSLog("Seeking in current item \(to)") NSLog("SEEK: Seeking in current item \(to)")
let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = to - currentTrackStartOffset let seekTime = to - currentTrackStartOffset
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
if !completed { guard completed else { return NSLog("SEEK: WARNING: seeking not completed (to \(seekTime)") }
NSLog("WARNING: seeking not completed (to \(seekTime)") guard let self = self else { return }
}
if continuePlaying { if continuePlaying {
self.play() self.resumePlayback()
} }
self.updateNowPlaying() self.updateNowPlaying()
} }
} }
} }
public func setPlaybackRate(_ rate: Float, observed: Bool = false) { public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1)
if self.audioPlayer.rate != rate { if self.audioPlayer.rate != rate {
NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
self.audioPlayer.rate = rate self.audioPlayer.rate = rate
} }
if rate > 0.0 && !(observed && rate == 1) {
self.tmpRate = rate
}
self.rate = rate self.rate = rate
self.updateNowPlaying() self.updateNowPlaying()
if playbackSpeedChanged {
self.tmpRate = rate
// Setup the time observer again at the new rate
self.setupTimeObserver()
}
} }
public func getCurrentTime() -> Double { public func getCurrentTime() -> Double? {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! guard let playbackSession = self.getPlaybackSession() else { return nil }
let currentTrackTime = self.audioPlayer.currentTime().seconds let currentTrackTime = self.audioPlayer.currentTime().seconds
let audioTrack = playbackSession.audioTracks[currentTrackIndex] let audioTrack = playbackSession.audioTracks[currentTrackIndex]
let startOffset = audioTrack.startOffset ?? 0.0 let startOffset = audioTrack.startOffset ?? 0.0
return startOffset + currentTrackTime return startOffset + currentTrackTime
} }
public func getPlayMethod() -> Int { public func getPlayMethod() -> Int? {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! guard let playbackSession = self.getPlaybackSession() else { return nil }
return playbackSession.playMethod return playbackSession.playMethod
} }
public func getPlaybackSessionId() -> String { public func getPlaybackSessionId() -> String {
return self.sessionId return self.sessionId
} }
public func getDuration() -> Double {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! public func getDuration() -> Double? {
guard let playbackSession = self.getPlaybackSession() else { return nil }
return playbackSession.duration return playbackSession.duration
} }
public func isPlaying() -> Bool {
return self.status > 0
}
// MARK: - Private // MARK: - Private
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset { private func createAsset(itemId:String, track:AudioTrack) -> AVAsset? {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! guard let playbackSession = self.getPlaybackSession() else { return nil }
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
// The only reason this is separate is because the filename needs to be encoded // The only reason this is separate is because the filename needs to be encoded
let filename = track.metadata?.filename ?? "" let filename = track.metadata?.filename ?? ""
@@ -332,77 +495,142 @@ class AudioPlayer: NSObject {
private func initAudioSession() { private func initAudioSession() {
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay]) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
try AVAudioSession.sharedInstance().setActive(true)
} catch { } catch {
NSLog("Failed to set AVAudioSession category") NSLog("Failed to set AVAudioSession category")
print(error) print(error)
} }
} }
private func markAudioSessionAs(active: Bool) {
do {
try AVAudioSession.sharedInstance().setActive(active)
} catch {
NSLog("Failed to set audio session as active=\(active)")
}
}
// MARK: - iOS audio session notifications
@objc private func handleInteruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .ended:
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
self.play(allowSeekBack: true)
}
default: ()
}
}
@objc private func handleRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
return
}
switch reason {
case .newDeviceAvailable: // New device found.
let session = AVAudioSession.sharedInstance()
let headphonesConnected = hasHeadphones(in: session.currentRoute)
if headphonesConnected {
// We should just let things be, as it's okay to go from speaker to headphones
}
case .oldDeviceUnavailable: // Old device removed.
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription {
let headphonesWereConnected = hasHeadphones(in: previousRoute)
if headphonesWereConnected {
// Removing headphones we should pause instead of keeping on playing
self.pause()
}
}
default: ()
}
}
private func hasHeadphones(in routeDescription: AVAudioSessionRouteDescription) -> Bool {
// Filter the outputs to only those with a port type of headphones.
return !routeDescription.outputs.filter({$0.portType == .headphones}).isEmpty
}
// MARK: - Now playing // MARK: - Now playing
private func setupRemoteTransportControls() { private func setupRemoteTransportControls() {
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
UIApplication.shared.beginReceivingRemoteControlEvents() UIApplication.shared.beginReceivingRemoteControlEvents()
} }
let commandCenter = MPRemoteCommandCenter.shared() let commandCenter = MPRemoteCommandCenter.shared()
let deviceSettings = Database.shared.getDeviceSettings()
commandCenter.playCommand.isEnabled = true commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [unowned self] event in commandCenter.playCommand.addTarget { [weak self] event in
play(allowSeekBack: true) self?.play(allowSeekBack: true)
return .success return .success
} }
commandCenter.pauseCommand.isEnabled = true commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [unowned self] event in commandCenter.pauseCommand.addTarget { [weak self] event in
pause() self?.pause()
return .success return .success
} }
commandCenter.skipForwardCommand.isEnabled = true commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [30] commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)]
commandCenter.skipForwardCommand.addTarget { [unowned self] event in commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let command = event.command as? MPSkipIntervalCommand else { guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent return .noSuchContent
} }
guard let currentTime = self?.getCurrentTime() else {
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue, from: "remote") return .commandFailed
}
self?.seek(currentTime + command.preferredIntervals[0].doubleValue, from: "remote")
return .success return .success
} }
commandCenter.skipBackwardCommand.isEnabled = true commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [30] commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)]
commandCenter.skipBackwardCommand.addTarget { [unowned self] event in commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let command = event.command as? MPSkipIntervalCommand else { guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent return .noSuchContent
} }
guard let currentTime = self?.getCurrentTime() else {
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue, from: "remote") return .commandFailed
}
self?.seek(currentTime - command.preferredIntervals[0].doubleValue, from: "remote")
return .success return .success
} }
commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { event in commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else { guard let event = event as? MPChangePlaybackPositionCommandEvent else {
return .noSuchContent return .noSuchContent
} }
self.seek(event.positionTime, from: "remote") self?.seek(event.positionTime, from: "remote")
return .success return .success
} }
commandCenter.changePlaybackRateCommand.isEnabled = true commandCenter.changePlaybackRateCommand.isEnabled = true
commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2] commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2]
commandCenter.changePlaybackRateCommand.addTarget { event in commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackRateCommandEvent else { guard let event = event as? MPChangePlaybackRateCommandEvent else {
return .noSuchContent return .noSuchContent
} }
self.setPlaybackRate(event.playbackRate) self?.setPlaybackRate(event.playbackRate)
return .success return .success
} }
} }
private func updateNowPlaying() { private func updateNowPlaying() {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NowPlayingInfo.shared.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate) if let duration = self.getDuration(), let currentTime = self.getCurrentTime() {
NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: rate)
}
} }
// MARK: - Observer // MARK: - Observer
@@ -0,0 +1,156 @@
//
// AudioPlayerSleepTimer.swift
// App
//
// Created by Ron Heft on 9/2/22.
//
import Foundation
import AVFoundation
extension AudioPlayer {
// MARK: - Public API
public func isSleepTimerSet() -> Bool {
return self.isCountdownSleepTimerSet() || self.isChapterSleepTimerSet()
}
public func getSleepTimeRemaining() -> Double? {
guard let currentTime = self.getCurrentTime() else { return nil }
// Return the player time until sleep
var sleepTimeRemaining: Double? = nil
if let chapterStopAt = self.sleepTimeChapterStopAt {
sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rate > 0 ? self.rate : 1.0)
} else if self.isCountdownSleepTimerSet() {
sleepTimeRemaining = self.sleepTimeRemaining
}
return sleepTimeRemaining
}
public func setSleepTimer(secondsUntilSleep: Double) {
NSLog("SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds")
self.removeSleepTimer()
self.sleepTimeRemaining = secondsUntilSleep
DispatchQueue.runOnMainQueue {
self.sleepTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
if self?.isPlaying() ?? false {
self?.decrementSleepTimerIfRunning()
}
}
}
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
}
public func setChapterSleepTimer(stopAt: Double) {
NSLog("SLEEP TIMER: Scheduling for chapter end \(stopAt)")
self.removeSleepTimer()
// Schedule the observation time
self.sleepTimeChapterStopAt = stopAt
// Get the current track
guard let playbackSession = self.getPlaybackSession() else { return }
let currentTrack = playbackSession.audioTracks[currentTrackIndex]
// Set values
guard let trackStartTime = currentTrack.startOffset else { return }
guard let trackEndTime = currentTrack.endOffset else { return }
// Verify the stop is during the current audio track
guard trackEndTime >= stopAt else { return }
// Schedule the observation time
let trackBasedStopTime = stopAt - trackStartTime
let sleepTime = CMTime(seconds: trackBasedStopTime, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
var times = [NSValue]()
times.append(NSValue(time: sleepTime))
self.sleepTimeChapterToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: self.queue) { [weak self] in
self?.handleSleepEnd()
}
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
}
public func increaseSleepTime(extraTimeInSeconds: Double) {
self.removeChapterSleepTimer()
guard let sleepTimeRemaining = self.sleepTimeRemaining else { return }
self.sleepTimeRemaining = sleepTimeRemaining + extraTimeInSeconds
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
}
public func decreaseSleepTime(removeTimeInSeconds: Double) {
self.removeChapterSleepTimer()
guard let sleepTimeRemaining = self.sleepTimeRemaining else { return }
self.sleepTimeRemaining = sleepTimeRemaining - removeTimeInSeconds
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
}
public func removeSleepTimer() {
self.sleepTimer?.invalidate()
self.sleepTimer = nil
self.removeChapterSleepTimer()
self.sleepTimeRemaining = nil
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self)
}
// MARK: - Internal helpers
internal func decrementSleepTimerIfRunning() {
if var sleepTimeRemaining = self.sleepTimeRemaining {
sleepTimeRemaining -= 1
self.sleepTimeRemaining = sleepTimeRemaining
// Handle the sleep if the timer has expired
if sleepTimeRemaining <= 0 {
self.handleSleepEnd()
}
}
}
private func handleSleepEnd() {
NSLog("SLEEP TIMER: Pausing audio")
self.pause()
self.removeSleepTimer()
}
private func removeChapterSleepTimer() {
if let token = self.sleepTimeChapterToken {
self.audioPlayer.removeTimeObserver(token)
}
self.sleepTimeChapterToken = nil
self.sleepTimeChapterStopAt = nil
}
internal func isChapterSleepTimerBeforeTime(_ time: Double) -> Bool {
if let chapterStopAt = self.sleepTimeChapterStopAt {
return chapterStopAt <= time
}
return false
}
internal func isCountdownSleepTimerSet() -> Bool {
return self.sleepTimeRemaining != nil
}
internal func isChapterSleepTimerSet() -> Bool {
return self.sleepTimeChapterStopAt != nil
}
}
+70 -160
View File
@@ -10,85 +10,6 @@ import RealmSwift
class PlayerHandler { class PlayerHandler {
private static var player: AudioPlayer? private static var player: AudioPlayer?
private static var playingTimer: Timer?
private static var pausedTimer: Timer?
private static var lastSyncTime: Double = 0.0
public static var sleepTimerChapterStopTime: Int? = nil
private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? {
get {
return _remainingSleepTime
}
set(time) {
if time != nil && time! < 0 {
_remainingSleepTime = nil
} else {
_remainingSleepTime = time
}
if _remainingSleepTime == nil {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: _remainingSleepTime)
} else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: _remainingSleepTime)
}
}
}
private static var listeningTimePassedSinceLastSync: Double = 0.0
public static var paused: Bool {
get {
guard let player = player else {
return true
}
return player.rate == 0.0
}
set(paused) {
if paused {
self.player?.pause()
} else {
self.player?.play()
self.pausedTimer?.invalidate()
}
}
}
public static func startTickTimer() {
DispatchQueue.runOnMainQueue {
NSLog("Starting the tick timer")
playingTimer?.invalidate()
pausedTimer?.invalidate()
playingTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
}
public static func stopTickTimer() {
NSLog("Stopping the tick timer")
playingTimer?.invalidate()
pausedTimer?.invalidate()
playingTimer = nil
}
private static func startPausedTimer() {
guard self.paused else { return }
self.pausedTimer?.invalidate()
self.pausedTimer = Timer.scheduledTimer(timeInterval: 30, target: self, selector: #selector(syncServerProgressDuringPause), userInfo: nil, repeats: true)
}
private static func cleanupOldSessions(currentSessionId: String?) {
let realm = try! Realm()
let oldSessions = realm.objects(PlaybackSession.self) .where({ $0.isActiveSession == true })
try! realm.write {
for s in oldSessions {
if s.id != currentSessionId {
s.isActiveSession = false
}
}
}
}
public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) { public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) {
guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return } guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return }
@@ -99,26 +20,21 @@ class PlayerHandler {
player = nil player = nil
} }
// Cleanup old sessions // Cleanup and sync old sessions
cleanupOldSessions(currentSessionId: sessionId) cleanupOldSessions(currentSessionId: sessionId)
Task { await PlayerProgress.shared.syncToServer() }
// Set now playing info // Set now playing info
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil)) NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
// Create the audio player // Create the audio player
player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate) player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate)
startTickTimer()
startPausedTimer()
} }
public static func stopPlayback() { public static func stopPlayback() {
// Pause playback first, so we can sync our current progress // Pause playback first, so we can sync our current progress
player?.pause() player?.pause()
// Stop updating progress before we destory the player, so we don't receive bad data
stopTickTimer()
player?.destroy() player?.destroy()
player = nil player = nil
@@ -127,6 +43,20 @@ class PlayerHandler {
NowPlayingInfo.shared.reset() NowPlayingInfo.shared.reset()
} }
public static var paused: Bool {
get {
guard let player = player else { return true }
return player.rate == 0.0
}
set(paused) {
if paused {
self.player?.pause()
} else {
self.player?.play()
}
}
}
public static func getCurrentTime() -> Double? { public static func getCurrentTime() -> Double? {
self.player?.getCurrentTime() self.player?.getCurrentTime()
} }
@@ -135,46 +65,70 @@ class PlayerHandler {
self.player?.setPlaybackRate(speed) self.player?.setPlaybackRate(speed)
} }
public static func getSleepTimeRemaining() -> Double? {
return self.player?.getSleepTimeRemaining()
}
public static func setSleepTime(secondsUntilSleep: Double) {
self.player?.setSleepTimer(secondsUntilSleep: secondsUntilSleep)
}
public static func setChapterSleepTime(stopAt: Double) {
self.player?.setChapterSleepTimer(stopAt: stopAt)
}
public static func increaseSleepTime(increaseSeconds: Double) {
self.player?.increaseSleepTime(extraTimeInSeconds: increaseSeconds)
}
public static func decreaseSleepTime(decreaseSeconds: Double) {
self.player?.decreaseSleepTime(removeTimeInSeconds: decreaseSeconds)
}
public static func cancelSleepTime() {
self.player?.removeSleepTimer()
}
public static func getPlayMethod() -> Int? { public static func getPlayMethod() -> Int? {
self.player?.getPlayMethod() self.player?.getPlayMethod()
} }
public static func getPlaybackSession() -> PlaybackSession? { public static func getPlaybackSession() -> PlaybackSession? {
guard let player = player else { return nil } guard let player = player else { return nil }
guard let session = Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) else { return nil } guard player.isInitialized() else { return nil }
return session
return Database.shared.getPlaybackSession(id: player.getPlaybackSessionId())
} }
public static func seekForward(amount: Double) { public static func seekForward(amount: Double) {
guard let player = player else { guard let player = player else { return }
return guard player.isInitialized() else { return }
} guard let currentTime = player.getCurrentTime() else { return }
let destinationTime = player.getCurrentTime() + amount let destinationTime = currentTime + amount
player.seek(destinationTime, from: "handler") player.seek(destinationTime, from: "handler")
} }
public static func seekBackward(amount: Double) { public static func seekBackward(amount: Double) {
guard let player = player else { guard let player = player else { return }
return guard player.isInitialized() else { return }
} guard let currentTime = player.getCurrentTime() else { return }
let destinationTime = player.getCurrentTime() - amount let destinationTime = currentTime - amount
player.seek(destinationTime, from: "handler") player.seek(destinationTime, from: "handler")
} }
public static func seek(amount: Double) { public static func seek(amount: Double) {
player?.seek(amount, from: "handler") guard let player = player else { return }
guard player.isInitialized() else { return }
player.seek(amount, from: "handler")
} }
public static func getMetdata() -> [String: Any]? { public static func getMetdata() -> [String: Any]? {
guard let player = player else { return nil } guard let player = player else { return nil }
guard player.isInitialized() else { return nil } guard player.isInitialized() else { return nil }
DispatchQueue.main.async {
syncPlayerProgress()
}
return [ return [
"duration": player.getDuration(), "duration": player.getDuration(),
"currentTime": player.getCurrentTime(), "currentTime": player.getCurrentTime(),
@@ -183,68 +137,24 @@ class PlayerHandler {
] ]
} }
private static func tick() { // MARK: - Helper logic
if !paused {
listeningTimePassedSinceLastSync += 1 private static func cleanupOldSessions(currentSessionId: String?) {
do {
if remainingSleepTime != nil { let realm = try Realm()
if sleepTimerChapterStopTime != nil { let oldSessions = realm.objects(PlaybackSession.self) .where({
let timeUntilChapterEnd = Double(sleepTimerChapterStopTime ?? 0) - (getCurrentTime() ?? 0) $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
if timeUntilChapterEnd <= 0 { })
paused = true try realm.write {
remainingSleepTime = nil for s in oldSessions {
} else { if s.id != currentSessionId {
remainingSleepTime = Int(timeUntilChapterEnd.rounded()) s.isActiveSession = false
} }
} else {
if remainingSleepTime! <= 0 {
paused = true
}
remainingSleepTime! -= 1
} }
} }
} catch {
debugPrint("Failed to cleanup sessions")
debugPrint(error)
} }
if listeningTimePassedSinceLastSync >= 5 {
syncPlayerProgress()
}
}
public static func syncPlayerProgress() {
guard let player = player else { return }
guard player.isInitialized() else { return }
guard let session = getPlaybackSession() else { return }
NSLog("Syncing player progress")
// Get current time
let playerCurrentTime = player.getCurrentTime()
// Prevent multiple sync requests
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
if (lastSyncTime > 0 && timeSinceLastSync < 1) {
NSLog("syncProgress last sync time was < 1 second so not syncing")
return
}
// Prevent a sync if we got junk data from the player (occurs when exiting out of memory
guard !playerCurrentTime.isNaN else { return }
lastSyncTime = Date().timeIntervalSince1970 // seconds
session.update {
session.currentTime = playerCurrentTime
session.timeListening += listeningTimePassedSinceLastSync
session.updatedAt = Date().timeIntervalSince1970 * 1000
}
listeningTimePassedSinceLastSync = 0
// Persist items in the database and sync to the server
if session.isLocal { PlayerProgress.syncFromPlayer() }
Task { await PlayerProgress.syncToServer() }
}
@objc public static func syncServerProgressDuringPause() {
Task { await PlayerProgress.syncFromServer() }
} }
} }
+109 -22
View File
@@ -10,38 +10,91 @@ import UIKit
import RealmSwift import RealmSwift
class PlayerProgress { class PlayerProgress {
public static let shared = PlayerProgress()
private static let TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 10.0
private init() {} private init() {}
public static func syncFromPlayer() {
updateLocalMediaProgressFromLocalSession() // MARK: - SYNC HOOKS
public func syncFromPlayer(currentTime: Double, includesPlayProgress: Bool, isStopping: Bool) async {
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromPlayer")
do {
let session = try updateLocalSessionFromPlayer(currentTime: currentTime, includesPlayProgress: includesPlayProgress)
try updateLocalMediaProgressFromLocalSession()
if let session = session {
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
}
} catch {
debugPrint("Failed to syncFromPlayer")
debugPrint(error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken)
} }
public static func syncToServer() async { public func syncToServer() async {
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer") let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer")
updateAllServerSessionFromLocalSession() do {
try await updateAllServerSessionFromLocalSession()
} catch {
debugPrint("Failed to syncToServer")
debugPrint(error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken) await UIApplication.shared.endBackgroundTask(backgroundToken)
} }
public static func syncFromServer() async { public func syncFromServer() async {
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer") let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer")
await updateLocalSessionFromServerMediaProgress() do {
try await updateLocalSessionFromServerMediaProgress()
} catch {
debugPrint("Failed to syncFromServer")
debugPrint(error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken) await UIApplication.shared.endBackgroundTask(backgroundToken)
} }
private static func updateLocalMediaProgressFromLocalSession() {
// MARK: - SYNC LOGIC
private func updateLocalSessionFromPlayer(currentTime: Double, includesPlayProgress: Bool) throws -> PlaybackSession? {
guard let session = PlayerHandler.getPlaybackSession() else { return nil }
guard !currentTime.isNaN else { return nil } // Prevent bad data on player stop
try session.update {
session.realm?.refresh()
let nowInSeconds = Date().timeIntervalSince1970
let nowInMilliseconds = nowInSeconds * 1000
let lastUpdateInMilliseconds = session.updatedAt ?? nowInMilliseconds
let lastUpdateInSeconds = lastUpdateInMilliseconds / 1000
let secondsSinceLastUpdate = nowInSeconds - lastUpdateInSeconds
session.currentTime = currentTime
session.updatedAt = nowInMilliseconds
if includesPlayProgress {
session.timeListening += secondsSinceLastUpdate
}
}
return session.freeze()
}
private func updateLocalMediaProgressFromLocalSession() throws {
guard let session = PlayerHandler.getPlaybackSession() else { return } guard let session = PlayerHandler.getPlaybackSession() else { return }
guard session.isLocal else { return } guard session.isLocal else { return }
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId) let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId)
guard let localMediaProgress = localMediaProgress else { guard let localMediaProgress = localMediaProgress else {
// Local media progress should have been created // Local media progress should have been created
// If we're here, it means a library id is invalid // If we're here, it means a library id is invalid
return return
} }
localMediaProgress.updateFromPlaybackSession(session) try localMediaProgress.updateFromPlaybackSession(session)
Database.shared.saveLocalMediaProgress(localMediaProgress)
NSLog("Local progress saved to the database") NSLog("Local progress saved to the database")
@@ -49,16 +102,46 @@ class PlayerProgress {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
} }
private static func updateAllServerSessionFromLocalSession() { private func updateAllServerSessionFromLocalSession() async throws {
let sessions = try! Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) try await withThrowingTaskGroup(of: Void.self) { [self] group in
for session in sessions { for session in try await Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) {
let session = session.freeze() let session = session.freeze()
Task { await updateServerSessionFromLocalSession(session) } group.addTask {
try await self.updateServerSessionFromLocalSession(session)
}
}
try await group.waitForAll()
} }
} }
private static func updateServerSessionFromLocalSession(_ session: PlaybackSession) async { private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws {
NSLog("Sending sessionId(\(session.id)) to server") var safeToSync = true
guard var session = session.thaw() else { return }
// We need to update and check the server time in a transaction for thread-safety
try session.update {
session.realm?.refresh()
let nowInMilliseconds = Date().timeIntervalSince1970 * 1000
let lastUpdateInMilliseconds = session.serverUpdatedAt
// If required, rate limit requests based on session last update
if rateLimitSync {
let timeSinceLastSync = nowInMilliseconds - lastUpdateInMilliseconds
let timeBetweenSessionSync = PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS * 1000
safeToSync = timeSinceLastSync > timeBetweenSessionSync
if !safeToSync {
return // This only exits the update block
}
}
session.serverUpdatedAt = nowInMilliseconds
}
session = session.freeze()
guard safeToSync else { return }
NSLog("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
var success = false var success = false
if session.isLocal { if session.isLocal {
@@ -68,16 +151,20 @@ class PlayerProgress {
success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id) success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id)
} }
// Remove old sessions after they synced with the server // Remove old sessions after they synced with the server
if success && !session.isActiveSession { if success && !session.isActiveSession {
NSLog("Deleting sessionId(\(session.id)) as is no longer active") if let session = session.thaw() {
session.thaw()?.delete() try session.delete()
}
} }
} }
private static func updateLocalSessionFromServerMediaProgress() async { private func updateLocalSessionFromServerMediaProgress() async throws {
NSLog("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server") NSLog("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
guard let session = try! await Realm().objects(PlaybackSession.self).last(where: { $0.isActiveSession == true })?.freeze() else { guard let session = try await Realm().objects(PlaybackSession.self).last(where: {
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
})?.freeze() else {
NSLog("updateLocalSessionFromServerMediaProgress: Failed to get session") NSLog("updateLocalSessionFromServerMediaProgress: Failed to get session")
return return
} }
@@ -105,7 +192,7 @@ class PlayerProgress {
if serverIsNewerThanLocal && currentTimeIsDifferent { if serverIsNewerThanLocal && currentTimeIsDifferent {
NSLog("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)") NSLog("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
guard let session = session.thaw() else { return } guard let session = session.thaw() else { return }
session.update { try session.update {
session.currentTime = serverCurrentTime session.currentTime = serverCurrentTime
session.updatedAt = serverLastUpdate session.updatedAt = serverLastUpdate
} }
+6 -1
View File
@@ -200,7 +200,12 @@ class ApiClient {
if let updates = response.localProgressUpdates { if let updates = response.localProgressUpdates {
for update in updates { for update in updates {
Database.shared.saveLocalMediaProgress(update) do {
try update.save()
} catch {
debugPrint("Failed to update local media progress")
debugPrint(error)
}
} }
} }
+8 -8
View File
@@ -9,15 +9,15 @@ import Foundation
import RealmSwift import RealmSwift
extension Object { extension Object {
func save() { func save() throws {
let realm = try! Realm() let realm = try Realm()
try! realm.write { try realm.write {
realm.add(self, update: .modified) realm.add(self, update: .modified)
} }
} }
func update(handler: () -> Void) { func update(handler: () -> Void) throws {
try! self.realm?.write { try self.realm?.write {
handler() handler()
} }
} }
@@ -33,12 +33,12 @@ extension EmbeddedObject {
} }
protocol Deletable { protocol Deletable {
func delete() func delete() throws
} }
extension Deletable where Self: Object { extension Deletable where Self: Object {
func delete() { func delete() throws {
try! self.realm?.write { try self.realm?.write {
self.realm?.delete(self) self.realm?.delete(self)
} }
} }
+113 -54
View File
@@ -20,29 +20,43 @@ class Database {
let realm = try! Realm() let realm = try! Realm()
let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
if config.index == 0 { if let existing = existing {
let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last do {
try existing.update {
if lastConfig != nil { existing.name = config.name
config.index = lastConfig!.index + 1 existing.address = config.address
} else { existing.userId = config.userId
config.index = 1 existing.username = config.username
} existing.token = config.token
}
do {
try realm.write {
if existing != nil {
realm.delete(existing!)
} }
realm.add(config) } catch {
NSLog("failed to update server config")
debugPrint(error)
} }
} catch(let exception) {
NSLog("failed to save server config") setLastActiveConfigIndex(index: existing.index)
debugPrint(exception) } else {
if config.index == 0 {
let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last
if lastConfig != nil {
config.index = lastConfig!.index + 1
} else {
config.index = 1
}
}
do {
try realm.write {
realm.add(config)
}
} catch(let exception) {
NSLog("failed to save server config")
debugPrint(exception)
}
setLastActiveConfigIndex(index: config.index)
} }
setLastActiveConfigIndex(index: config.index)
} }
public func deleteServerConnectionConfig(id: String) { public func deleteServerConnectionConfig(id: String) {
@@ -112,48 +126,83 @@ class Database {
} }
public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] { public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] {
let realm = try! Realm() do {
return Array(realm.objects(LocalLibraryItem.self)) let realm = try Realm()
return Array(realm.objects(LocalLibraryItem.self))
} catch {
debugPrint(error)
return []
}
} }
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? { public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
let realm = try! Realm() do {
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId }) let realm = try Realm()
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId })
} catch {
debugPrint(error)
return nil
}
} }
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? { public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
let realm = try! Realm() do {
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId) let realm = try Realm()
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId)
} catch {
debugPrint(error)
return nil
}
} }
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) throws {
let realm = try! Realm() let realm = try Realm()
try! realm.write { realm.add(localLibraryItem, update: .modified) } try realm.write { realm.add(localLibraryItem, update: .modified) }
} }
public func getLocalFile(localFileId: String) -> LocalFile? { public func getLocalFile(localFileId: String) -> LocalFile? {
let realm = try! Realm() do {
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId) let realm = try Realm()
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId)
} catch {
debugPrint(error)
return nil
}
} }
public func getDownloadItem(downloadItemId: String) -> DownloadItem? { public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
let realm = try! Realm() do {
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) let realm = try Realm()
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId)
} catch {
debugPrint(error)
return nil
}
} }
public func getDownloadItem(libraryItemId: String) -> DownloadItem? { public func getDownloadItem(libraryItemId: String) -> DownloadItem? {
let realm = try! Realm() do {
return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first let realm = try Realm()
return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first
} catch {
debugPrint(error)
return nil
}
} }
public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? { public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? {
let realm = try! Realm() do {
return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first let realm = try Realm()
return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first
} catch {
debugPrint(error)
return nil
}
} }
public func saveDownloadItem(_ downloadItem: DownloadItem) { public func saveDownloadItem(_ downloadItem: DownloadItem) throws {
let realm = try! Realm() let realm = try Realm()
return try! realm.write { realm.add(downloadItem, update: .modified) } return try realm.write { realm.add(downloadItem, update: .modified) }
} }
public func getDeviceSettings() -> DeviceSettings { public func getDeviceSettings() -> DeviceSettings {
@@ -162,31 +211,41 @@ class Database {
} }
public func getAllLocalMediaProgress() -> [LocalMediaProgress] { public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
let realm = try! Realm() do {
return Array(realm.objects(LocalMediaProgress.self)) let realm = try Realm()
} return Array(realm.objects(LocalMediaProgress.self))
} catch {
public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { debugPrint(error)
let realm = try! Realm() return []
try! realm.write { realm.add(mediaProgress, update: .modified) } }
} }
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? { public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? {
let realm = try! Realm() do {
return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) let realm = try Realm()
return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
} catch {
debugPrint(error)
return nil
}
} }
public func removeLocalMediaProgress(localMediaProgressId: String) { public func removeLocalMediaProgress(localMediaProgressId: String) throws {
let realm = try! Realm() let realm = try Realm()
try! realm.write { try realm.write {
let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
realm.delete(progress!) realm.delete(progress!)
} }
} }
public func getPlaybackSession(id: String) -> PlaybackSession? { public func getPlaybackSession(id: String) -> PlaybackSession? {
let realm = try! Realm() do {
return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id) let realm = try Realm()
return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id)
} catch {
debugPrint(error)
return nil
}
} }
} }
+11 -6
View File
@@ -59,12 +59,17 @@ class NowPlayingInfo {
} }
} }
public func update(duration: Double, currentTime: Double, rate: Float) { public func update(duration: Double, currentTime: Double, rate: Float) {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration // Update on the main to prevent access collisions
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime DispatchQueue.main.async { [weak self] in
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate if let self = self {
nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo
}
}
} }
public func reset() { public func reset() {
+9 -5
View File
@@ -9,10 +9,17 @@ import Foundation
import RealmSwift import RealmSwift
class Store { class Store {
private static var _serverConfig: ServerConnectionConfig?
public static var serverConfig: ServerConnectionConfig? { public static var serverConfig: ServerConnectionConfig? {
get { get {
return _serverConfig do {
// Fetch each time, as holding onto a live or frozen realm object is bad
let index = Database.shared.getLastActiveConfigIndex()
let realm = try Realm()
return realm.objects(ServerConnectionConfig.self).first(where: { $0.index == index })
} catch {
debugPrint(error)
return nil
}
} }
set(updated) { set(updated) {
if updated != nil { if updated != nil {
@@ -20,9 +27,6 @@ class Store {
} else { } else {
Database.shared.setLastActiveConfigIndexToNil() Database.shared.setLastActiveConfigIndexToNil()
} }
// Make safe for accessing on all threads
_serverConfig = updated?.freeze()
} }
} }
} }
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.56-beta", "version": "0.9.58-beta",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.56-beta", "version": "0.9.57-beta",
"dependencies": { "dependencies": {
"@capacitor/android": "^3.4.3", "@capacitor/android": "^3.4.3",
"@capacitor/app": "^1.1.1", "@capacitor/app": "^1.1.1",
@@ -32982,4 +32982,4 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.56-beta", "version": "0.9.58-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
+7
View File
@@ -583,13 +583,20 @@ export default {
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
} }
},
libraryChanged(libraryId) {
if (this.libraryItem.libraryId !== libraryId) {
this.$router.replace('/bookshelf')
}
} }
}, },
mounted() { mounted() {
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
this.$socket.on('item_updated', this.itemUpdated) this.$socket.on('item_updated', this.itemUpdated)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
this.$socket.off('item_updated', this.itemUpdated) this.$socket.off('item_updated', this.itemUpdated)
} }
+18
View File
@@ -60,6 +60,24 @@ class LocalStorage {
} }
} }
async setPlayerLock(lock) {
try {
await Storage.set({ key: 'playerLock', value: lock ? '1' : '0' })
} catch (error) {
console.error('[LocalStorage] Failed to set player lock', error)
}
}
async getPlayerLock() {
try {
var obj = await Storage.get({ key: 'playerLock' }) || {}
return obj.value === '1'
} catch (error) {
console.error('[LocalStorage] Failed to get player lock', error)
return false
}
}
async setBookshelfListView(useIt) { async setBookshelfListView(useIt) {
try { try {
await Storage.set({ key: 'bookshelfListView', value: useIt ? '1' : '0' }) await Storage.set({ key: 'bookshelfListView', value: useIt ? '1' : '0' })
+3 -1
View File
@@ -22,7 +22,7 @@ export const getters = {
} }
export const actions = { export const actions = {
fetch({ state, commit, rootState }, libraryId) { fetch({ state, commit, dispatch, rootState }, libraryId) {
if (!rootState.user || !rootState.user.user) { if (!rootState.user || !rootState.user.user) {
console.error('libraries/fetch - User not set') console.error('libraries/fetch - User not set')
return false return false
@@ -35,6 +35,8 @@ export const actions = {
var filterData = data.filterdata var filterData = data.filterdata
var issues = data.issues || 0 var issues = data.issues || 0
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
commit('addUpdate', library) commit('addUpdate', library)
commit('setLibraryIssues', issues) commit('setLibraryIssues', issues)
commit('setLibraryFilterData', filterData) commit('setLibraryFilterData', filterData)
+30
View File
@@ -48,6 +48,36 @@ export const getters = {
} }
export const actions = { export const actions = {
// When changing libraries make sure sort and filter is still valid
checkUpdateLibrarySortFilter({ state, dispatch, commit }, mediaType) {
var settingsUpdate = {}
if (mediaType == 'podcast') {
if (state.settings.mobileOrderBy == 'media.metadata.authorName' || state.settings.mobileOrderBy == 'media.metadata.authorNameLF') {
settingsUpdate.mobileOrderBy = 'media.metadata.author'
}
if (state.settings.mobileOrderBy == 'media.duration') {
settingsUpdate.mobileOrderBy = 'media.numTracks'
}
if (state.settings.mobileOrderBy == 'media.metadata.publishedYear') {
settingsUpdate.mobileOrderBy = 'media.metadata.title'
}
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
var filterByFirstPart = (state.settings.mobileFilterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all'
}
} else {
if (state.settings.mobileOrderBy == 'media.metadata.author') {
settingsUpdate.mobileOrderBy = 'media.metadata.authorName'
}
if (state.settings.mobileOrderBy == 'media.numTracks') {
settingsUpdate.mobileOrderBy = 'media.duration'
}
}
if (Object.keys(settingsUpdate).length) {
dispatch('updateUserSettings', settingsUpdate)
}
},
async updateUserSettings({ state, commit }, payload) { async updateUserSettings({ state, commit }, payload) {
if (state.serverConnectionConfig) { if (state.serverConnectionConfig) {
var updatePayload = { var updatePayload = {