diff --git a/android/app/build.gradle b/android/app/build.gradle index a8a43711..50bf7e82 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,8 +33,8 @@ android { applicationId "com.audiobookshelf.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 87 - versionName "0.9.56-beta" + versionCode 88 + versionName "0.9.57-beta" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index 9e4997f5..807457e6 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -1,69 +1,11 @@ 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 com.audiobookshelf.app.R -import com.audiobookshelf.app.device.DeviceManager +import androidx.media.utils.MediaConstants 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?, - 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 @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @JsonSubTypes( @@ -121,7 +63,7 @@ class Podcast( } @JsonIgnore 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) var index = 1 @@ -142,7 +84,7 @@ class Podcast( } @JsonIgnore 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) var index = 1 @@ -293,24 +235,51 @@ data class PodcastEpisode( var serverEpisodeId:String? // For local podcasts to match with server podcasts ) { @JsonIgnore - fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat { - var coverUri:Uri = Uri.EMPTY - val podcast = if(libraryItem is LocalLibraryItem) { - coverUri = libraryItem.getCoverUri() - libraryItem.media as Podcast + fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat { + val coverUri = if(libraryItem is LocalLibraryItem) { + libraryItem.getCoverUri() } else { - coverUri = (libraryItem as LibraryItem).getCoverUri() - (libraryItem as LibraryItem).media as Podcast + (libraryItem as LibraryItem).getCoverUri() } - 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 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 { + 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() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 00c05051..a4402416 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -1,5 +1,6 @@ package com.audiobookshelf.app.data +import android.support.v4.media.MediaDescriptionCompat import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonSubTypes @@ -92,7 +93,10 @@ data class LocalFolder( JsonSubTypes.Type(LibraryItem::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) data class DeviceInfo( diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt new file mode 100644 index 00000000..48dd51fb --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt @@ -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(serverItem.toString()) + + var episode:PodcastEpisode? = null + if (serverItem.has("recentEpisode")) { + episode = jacksonMapper.readValue(serverItem.get("recentEpisode").toString()) + } + + val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate") + return ItemInProgress(libraryItem, episode, progressLastUpdate, false) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt new file mode 100644 index 00000000..4166a6e3 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -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?, + 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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index c1b2e2ce..390cfd14 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -1,10 +1,8 @@ package com.audiobookshelf.app.data -import android.content.Context import android.net.Uri import android.os.Bundle import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat import android.util.Log import androidx.media.utils.MediaConstants import com.audiobookshelf.app.R @@ -14,8 +12,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties import java.util.* @JsonIgnoreProperties(ignoreUnknown = true) -data class LocalLibraryItem( - var id:String, +class LocalLibraryItem( + id:String, var folderId:String, var basePath:String, var absolutePath:String, @@ -32,7 +30,7 @@ data class LocalLibraryItem( var serverAddress:String?, var serverUserId:String?, var libraryItemId:String? - ) : LibraryItemWrapper() { + ) : LibraryItemWrapper(id) { @get:JsonIgnore val title get() = media.metadata.title @get:JsonIgnore @@ -98,16 +96,38 @@ data class LocalLibraryItem( } @JsonIgnore - fun getMediaMetadata(): MediaMetadataCompat { + override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { val coverUri = getCoverUri() - 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, coverUri.toString()) - putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) - }.build() + 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(coverUri) + .setSubtitle(authorName) + .setExtras(extras) + .build() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 0ef698bb..3199e8e8 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -2,12 +2,8 @@ package com.audiobookshelf.app.media import android.app.Activity import android.content.Context -import android.os.Bundle import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat import android.util.Log -import androidx.media.utils.MediaConstants import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.server.ApiHandler @@ -26,11 +22,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { var selectedLibraryItems = mutableListOf() var selectedLibraryId = "" - var selectedLibraryItemWrapper:LibraryItemWrapper? = null var selectedPodcast:Podcast? = null var selectedLibraryItemId:String? = null - var serverPodcastEpisodes = listOf() + var podcastEpisodeLibraryItemMap = mutableMapOf() var serverLibraryCategories = listOf() + var serverItemsInProgress = listOf() var serverLibraries = listOf() var serverConfigIdUsed:String? = null 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() if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) { - serverPodcastEpisodes = listOf() + podcastEpisodeLibraryItemMap = mutableMapOf() serverLibraryCategories = listOf() serverLibraries = listOf() serverLibraryItems = mutableListOf() @@ -80,13 +76,13 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { } } - fun loadLibraryCategories(libraryId:String, cb: (List) -> Unit) { - if (serverLibraryCategories.isNotEmpty()) { - cb(serverLibraryCategories) + fun loadItemsInProgressForAllLibraries(cb: (List) -> Unit) { + if (serverItemsInProgress.isNotEmpty()) { + cb(serverItemsInProgress) } else { - apiHandler.getLibraryCategories(libraryId) { - serverLibraryCategories = it - cb(it) + apiHandler.getAllItemsInProgress { itemsInProgress -> + serverItemsInProgress = itemsInProgress + cb(serverItemsInProgress) } } } @@ -129,44 +125,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { loadLibraryItem(libraryItemId) { libraryItemWrapper -> Log.d(tag, "Loaded Podcast library item $libraryItemWrapper") - selectedLibraryItemWrapper = libraryItemWrapper - libraryItemWrapper?.let { if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { - serverPodcastEpisodes = listOf() cb(mutableListOf()) } else { val podcast = libraryItemWrapper.media as Podcast - serverPodcastEpisodes = podcast.episodes ?: listOf() selectedLibraryItemId = libraryItemWrapper.id selectedPodcast = podcast val children = podcast.episodes?.map { podcastEpisode -> 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 description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress) + val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) } } else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { - serverPodcastEpisodes = listOf() cb(mutableListOf()) } else { val podcast = libraryItemWrapper.media as Podcast - serverPodcastEpisodes = podcast.episodes ?: listOf() + podcast.episodes?.forEach { podcastEpisode -> + podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode) + } selectedLibraryItemId = libraryItemWrapper.id selectedPodcast = podcast val children = podcast.episodes?.map { podcastEpisode -> - val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper) 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) } 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 loadLocalCategory():List { - val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") - val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") - val cats = mutableListOf() - 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) -> Unit) { + fun loadAndroidAutoItems(cb: () -> Unit) { Log.d(tag, "Load android auto items") - val cats = mutableListOf() - - val localCategories = loadLocalCategory() - cats.addAll(localCategories) // Check if any valid server connection if not use locally downloaded books checkSetValidServerConnectionConfig { isConnected -> @@ -304,40 +277,29 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { loadLibraries { libraries -> if (libraries.isEmpty()) { Log.w(tag, "No libraries returned from server request") - cb(cats) // Return download category only + cb() } else { val library = libraries[0] Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") - loadLibraryCategories(library.id) { libraryCategories -> - - // Only using book or podcast library categories for now - libraryCategories.forEach { - - // 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) - } - } + loadItemsInProgressForAllLibraries { itemsInProgress -> + itemsInProgress.forEach { + val libraryItem = it.libraryItemWrapper as LibraryItem + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) } - - // Log.d(tag, "Found library category ${it.label} with type ${it.type}") - if (it.type == library.mediaType) { - // Log.d(tag, "Using library category ${it.id}") - cats.add(it) + if (it.episode != null) { + podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode) } } - cb(cats) + cb() // Fully loaded } } } - } else { // Not connected/no internet sent downloaded cats only - cb(cats) + } else { // Not connected to server + cb() } } } @@ -355,12 +317,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { if (id.startsWith("local")) { return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id) } else { - val podcastEpisode = serverPodcastEpisodes.find { it.id == id } - return if (podcastEpisode != null && selectedLibraryItemWrapper != null) { - LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode) - } else { - null - } + return podcastEpisodeLibraryItemMap[id] } } @@ -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 { val lhsLength = lhs.length + 1 val rhsLength = rhs.length + 1 diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index cf519f8e..099996c3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -10,7 +10,7 @@ import com.audiobookshelf.app.data.* class BrowseTree( val context: Context, - libraryCategories: List, + itemsInProgress: List, libraries: List ) { private val mediaIdToChildren = mutableMapOf>() @@ -21,8 +21,7 @@ class BrowseTree( * @param drawableId - drawable res id * @return - uri */ - fun getUriToDrawable(context: Context, - @AnyRes drawableId: Int): Uri { + fun getUriToDrawable(@AnyRes drawableId: Int): Uri { return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.resources.getResourcePackageName(drawableId) + '/' + context.resources.getResourceTypeName(drawableId) @@ -32,38 +31,26 @@ class BrowseTree( init { 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_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() val downloadsMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString()) }.build() val librariesMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT) 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() - // Server continue Listening cat - libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory -> - 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 (!itemsInProgress.isEmpty()) { + rootList += continueListeningMetadata } if (libraries.isNotEmpty()) { @@ -78,23 +65,6 @@ class BrowseTree( } 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 } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 0f2c3e6e..a2c1868b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -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 AUTO_MEDIA_ROOT = "/" - private val ALL_ROOT = "__ALL__" private val LIBRARIES_ROOT = "__LIBRARIES__" + private val DOWNLOADS_ROOT = "__DOWNLOADS__" + private val CONTINUE_ROOT = "__CONTINUE__" private lateinit var browseTree:BrowseTree @@ -851,8 +852,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { override fun onLoadChildren(parentMediaId: String, result: Result>) { 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() if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes @@ -864,53 +863,67 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> val children = libraryItems.map { libraryItem -> - val libraryItemMediaMetadata = libraryItem.getMediaMetadata() - 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 { - val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemMediaMetadata.description.mediaId } - val description = mediaManager.getMediaDescriptionFromMediaMetadata(libraryItemMediaMetadata, progress) + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val description = libraryItem.getMediaDescription(progress) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } } result.sendResult(children as MutableList?) } - } else if (parentMediaId == "__DOWNLOADS__") { // Load downloads + } else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") val localBrowseItems:MutableList = mutableListOf() localBooks.forEach { localLibraryItem -> - val mediaMetadata = localLibraryItem.getMediaMetadata() - val progress = DeviceManager.dbManager.getLocalMediaProgress(mediaMetadata.description.mediaId ?: "") - val description = mediaManager.getMediaDescriptionFromMediaMetadata(mediaMetadata, progress) + val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id) + val description = localLibraryItem.getMediaDescription(progress) localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } localPodcasts.forEach { localLibraryItem -> - val mediaMetadata = localLibraryItem.getMediaMetadata() - localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + val mediaDescription = localLibraryItem.getMediaDescription(null) + localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) } result.sendResult(localBrowseItems) - } else { // Load categories - mediaManager.loadAndroidAutoItems { libraryCategories -> - browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) + } else if (parentMediaId == CONTINUE_ROOT) { + val localBrowseItems:MutableList = mutableListOf() + 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 -> - Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag") - - 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) - } + Log.d(tag, "Loading Browser Media Item ${item.description.title}") + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) } result.sendResult(children as MutableList?) } @@ -919,10 +932,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { override fun onSearch(query: String, extras: Bundle?, result: Result>) { result.detach() - mediaManager.loadAndroidAutoItems() { libraryCategories -> - browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) - val children = browseTree[ALL_ROOT]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + mediaManager.loadAndroidAutoItems { + browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries) + val children = browseTree[LIBRARIES_ROOT]?.map { item -> + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) } result.sendResult(children as MutableList?) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 3893b558..bbf3a0ab 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -189,21 +189,16 @@ class ApiHandler(var ctx:Context) { } } - fun getLibraryCategories(libraryId:String, cb: (List) -> Unit) { - getRequest("/api/libraries/$libraryId/personalized", null, null) { - val items = mutableListOf() - if (it.has("value")) { - val array = it.getJSONArray("value") + fun getAllItemsInProgress(cb: (List) -> Unit) { + getRequest("/api/me/items-in-progress", null, null) { + val items = mutableListOf() + if (it.has("libraryItems")) { + val array = it.getJSONArray("libraryItems") for (i in 0 until array.length()) { val jsobj = array.get(i) as JSONObject - val type = jsobj.get("type").toString() - // Only support for podcast and book in android auto - if (type == "podcast" || type == "book") { - jsobj.put("isLocal", false) - val item = jacksonMapper.readValue(jsobj.toString()) - items.add(item) - } + val itemInProgress = ItemInProgress.makeFromServerObject(jsobj) + items.add(itemInProgress) } } cb(items) diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 61a94a19..e8353625 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -8,9 +8,7 @@ cast
- - more_vert - + more_vert

{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}

@@ -66,23 +64,24 @@
- first_page - {{ jumpBackwardsIcon }} + first_page + {{ jumpBackwardsIcon }}
{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}
- {{ jumpForwardIcon }} - last_page + {{ jumpForwardIcon }} + last_page
-
+
+

0:00

@@ -95,6 +94,24 @@
+ + + +
@@ -139,10 +156,12 @@ export default { touchStartTime: 0, touchEndY: 0, useChapterTrack: false, + lockUi: false, isLoading: false, touchTrackStart: false, dragPercent: 0, - syncStatus: 0 + syncStatus: 0, + showMoreMenuDialog: false } }, watch: { @@ -153,17 +172,24 @@ export default { }, computed: { menuItems() { - var items = [] - items.push({ - text: 'Chapter Track', - value: 'chapter_track', - icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank' - }) - items.push({ - text: 'Close Player', - value: 'close', - icon: 'close' - }) + var items = [ + { + text: 'Chapter Track', + value: 'chapter_track', + icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank' + }, + { + text: this.lockUi ? 'Unlock Player' : 'Lock Player', + value: 'lock', + icon: this.lockUi ? 'lock' : 'lock_open' + }, + { + text: 'Close Player', + value: 'close', + icon: 'close' + } + ] + return items }, jumpForwardIcon() { @@ -310,7 +336,7 @@ export default { } }, 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 }, selectChapter(chapter) { @@ -460,6 +486,10 @@ export default { this.$refs.playedTrack.style.width = ptWidth + '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.$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' @@ -487,7 +517,7 @@ export default { } }, clickTrack(e) { - if (this.isLoading) return + if (this.isLoading || this.lockUi) return if (!this.showFullscreen) { // Track not clickable on mini-player return @@ -612,18 +642,22 @@ export default { } }, clickMenuAction(action) { - if (action === 'chapter_track') { - this.useChapterTrack = !this.useChapterTrack + this.showMoreMenuDialog = false + 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.updateTrack() this.updateReadyTrack() - }) - this.$localStore.setUseChapterTrack(this.useChapterTrack) - } else if (action === 'close') { - this.closePlayback() - } + this.$localStore.setUseChapterTrack(this.useChapterTrack) + } else if (action === 'close') { + this.closePlayback() + } + }) }, forceCloseDropdownMenu() { if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) { @@ -705,6 +739,7 @@ export default { }, async init() { this.useChapterTrack = await this.$localStore.getUseChapterTrack() + this.lockUi = await this.$localStore.getPlayerLock() this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) @@ -737,8 +772,12 @@ export default { mounted() { this.updateScreenSize() if (screen.orientation) { + // Not available on ios screen.orientation.addEventListener('change', this.screenOrientationChange) + } else { + document.addEventListener('orientationchange', this.screenOrientationChange) } + window.addEventListener('resize', this.screenOrientationChange) this.$eventBus.$on('minimize-player', this.minimizePlayerEvt) document.body.addEventListener('touchstart', this.touchstart) @@ -748,8 +787,12 @@ export default { }, beforeDestroy() { if (screen.orientation) { + // Not available on ios screen.orientation.removeEventListener('change', this.screenOrientationChange) + } else { + document.removeEventListener('orientationchange', this.screenOrientationChange) } + window.removeEventListener('resize', this.screenOrientationChange) if (this.playbackSession) { console.log('[AudioPlayer] Before destroy closing playback') diff --git a/components/modals/Dialog.vue b/components/modals/Dialog.vue index f1c37bf4..31041ff8 100644 --- a/components/modals/Dialog.vue +++ b/components/modals/Dialog.vue @@ -10,11 +10,13 @@
diff --git a/components/modals/LibrariesModal.vue b/components/modals/LibrariesModal.vue index c7fc936e..e5b50905 100644 --- a/components/modals/LibrariesModal.vue +++ b/components/modals/LibrariesModal.vue @@ -50,8 +50,9 @@ export default { methods: { async clickedOption(lib) { this.show = false + if (lib.id === this.currentLibraryId) return 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) } }, diff --git a/components/modals/OrderModal.vue b/components/modals/OrderModal.vue index c1f364b4..79d7e23e 100644 --- a/components/modals/OrderModal.vue +++ b/components/modals/OrderModal.vue @@ -39,6 +39,10 @@ export default { text: 'Author (Last, First)', value: 'media.metadata.authorNameLF' }, + { + text: 'Published Year', + value: 'media.metadata.publishedYear' + }, { text: 'Added At', value: 'addedAt' diff --git a/components/tables/collection/BookTableRow.vue b/components/tables/collection/BookTableRow.vue index 54db2cbf..72ca46d8 100644 --- a/components/tables/collection/BookTableRow.vue +++ b/components/tables/collection/BookTableRow.vue @@ -1,17 +1,17 @@ diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 2b6c149a..3c92a7ed 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; }; E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.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 */; }; /* End PBXBuildFile section */ @@ -117,6 +118,7 @@ E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = ""; }; E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = ""; }; E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = ""; }; + E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSleepTimer.swift; sourceTree = ""; }; E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ @@ -145,6 +147,7 @@ isa = PBXGroup; children = ( 3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */, + E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */, 3ABF618E2804325C0070250E /* PlayerHandler.swift */, E9E985F728B02D9400957F23 /* PlayerProgress.swift */, ); @@ -438,6 +441,7 @@ E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */, E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */, E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */, + E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */, E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */, E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */, 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */, @@ -595,12 +599,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; 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\""; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -619,12 +623,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; 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_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 99d44fe4..01115c80 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -6,12 +6,13 @@ import RealmSwift class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + var backgroundCompletionHandler: (() -> Void)? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. let configuration = Realm.Configuration( - schemaVersion: 2, + schemaVersion: 4, migrationBlock: { migration, oldSchemaVersion in if (oldSchemaVersion < 1) { NSLog("Realm schema version was \(oldSchemaVersion)") @@ -19,6 +20,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { 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 @@ -64,6 +73,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // tracking app url opens, make sure to keep this call 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, with event: UIEvent?) { super.touchesBegan(touches, with: event) diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift index 34e00a77..0ec2d225 100644 --- a/ios/App/App/plugins/AbsAudioPlayer.swift +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -38,9 +38,11 @@ public class AbsAudioPlayer: CAPPlugin { do { // 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 { - await PlayerProgress.syncFromServer() + await PlayerProgress.shared.syncFromServer() try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate) } } catch { @@ -79,9 +81,9 @@ public class AbsAudioPlayer: CAPPlugin { NSLog("Failed to get local playback session") return call.resolve([:]) } - playbackSession.save() do { + try playbackSession.save() try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate) call.resolve(try playbackSession.asDictionary()) } catch(let exception) { @@ -91,8 +93,8 @@ public class AbsAudioPlayer: CAPPlugin { } } else { // Playing from the server ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in - session.save() do { + try session.save() try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate) call.resolve(try session.asDictionary()) } catch(let exception) { @@ -120,7 +122,7 @@ public class AbsAudioPlayer: CAPPlugin { @objc func setPlaybackSpeed(_ call: CAPPluginCall) { let playbackRate = call.getFloat("value", 1.0) let settings = PlayerSettings.main() - settings.update { + try? settings.update { settings.playbackRate = playbackRate } PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate) @@ -166,48 +168,49 @@ public class AbsAudioPlayer: CAPPlugin { @objc func decreaseSleepTime(_ call: CAPPluginCall) { 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 currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } + guard let time = Double(timeString) 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() } + @objc func increaseSleepTime(_ call: CAPPluginCall) { 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 currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } + guard let time = Double(timeString) 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() } + @objc func setSleepTimer(_ call: CAPPluginCall) { guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } - guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } - let timeSeconds = time / 1000 + guard let time = Double(timeString) else { return call.resolve([ "success": false ]) } + let isChapterTime = call.getBool("isChapterTime", false) - NSLog("chapter time: \(call.getBool("isChapterTime", false))") + let seconds = time / 1000 - if call.getBool("isChapterTime", false) { - let timeToPause = timeSeconds - Int(PlayerHandler.getCurrentTime() ?? 0) - if timeToPause < 0 { return call.resolve([ "success": false ]) } - - PlayerHandler.sleepTimerChapterStopTime = timeSeconds - PlayerHandler.remainingSleepTime = timeToPause + NSLog("chapter time: \(isChapterTime)") + if isChapterTime { + PlayerHandler.setChapterSleepTime(stopAt: seconds) 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) { - PlayerHandler.remainingSleepTime = nil - PlayerHandler.sleepTimerChapterStopTime = nil + PlayerHandler.cancelSleepTime() call.resolve() } + @objc func getSleepTimerTime(_ call: CAPPluginCall) { call.resolve([ - "value": PlayerHandler.remainingSleepTime + "value": PlayerHandler.getSleepTimeRemaining() ]) } @@ -219,7 +222,7 @@ public class AbsAudioPlayer: CAPPlugin { @objc func sendSleepTimerSet() { 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 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 { + try session.save() + PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate) self.sendPlaybackSession(session: try session.asDictionary()) + self.sendMetadata() } catch(let exception) { - NSLog("failed to convert session to json") + NSLog("Failed to start transcoded session") debugPrint(exception) } - - self.sendMetadata() } } else { self.notifyListeners("onPlaybackFailed", data: [ diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index a78619df..7f3d9f54 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -43,7 +43,7 @@ public class AbsDatabase: CAPPlugin { let config = ServerConnectionConfig() config.id = id ?? "" - config.index = 1 + config.index = 0 config.name = name config.address = address config.userId = userId @@ -51,7 +51,8 @@ public class AbsDatabase: CAPPlugin { config.token = token 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) { let id = call.getString("serverConnectionConfigId", "") @@ -139,7 +140,7 @@ public class AbsDatabase: CAPPlugin { call.reject("localMediaProgressId not specificed") return } - Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId) + try? Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId) call.resolve() } @@ -171,15 +172,15 @@ public class AbsDatabase: CAPPlugin { 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 { call.reject("Local media progress not found or created") return } - localMediaProgress.updateFromServerMediaProgress(serverMediaProgress) NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress") - Database.shared.saveLocalMediaProgress(localMediaProgress) + try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress) + call.resolve(try localMediaProgress.asDictionary()) } catch { call.reject("Failed to sync media progress") @@ -195,31 +196,36 @@ public class AbsDatabase: CAPPlugin { NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)") - let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) - guard let localMediaProgress = localMediaProgress else { - call.resolve(["error": "Library Item not found"]) - return - } + do { + let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) + guard let localMediaProgress = localMediaProgress else { + call.resolve(["error": "Library Item not found"]) + return + } - // Update finished status - localMediaProgress.updateIsFinished(isFinished) - Database.shared.saveLocalMediaProgress(localMediaProgress) - - // Build API response - let progressDictionary = try? localMediaProgress.asDictionary() - var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""] - - // Send update to the server if logged in - let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil - let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId - if hasLinkedServer && loggedIntoServer { - response["server"] = true - let payload = ["isFinished": isFinished] - ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) { + // Update finished status + try localMediaProgress.updateIsFinished(isFinished) + + // Build API response + let progressDictionary = try? localMediaProgress.asDictionary() + var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""] + + // Send update to the server if logged in + let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil + let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId + if hasLinkedServer && loggedIntoServer { + response["server"] = true + let payload = ["isFinished": isFinished] + ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) { + call.resolve(response) + } + } else { call.resolve(response) } - } else { - call.resolve(response) + } catch { + debugPrint(error) + call.resolve(["error": "Failed to mark as complete"]) + return } } diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index a5dbffa2..22c42730 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -15,9 +15,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] private lazy var session: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader") let queue = OperationQueue() 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 var downloadItemProgress = [String: DownloadItem]() @@ -28,7 +29,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in - let realm = try! Realm() + let realm = try Realm() try realm.write { downloadItemPart.progress = 100 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) { do { guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription } @@ -139,7 +152,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } self.handleDownloadTaskCompleteFromDownloadItem(item) if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) { - item.delete() + try? item.delete() } } @@ -181,7 +194,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } else { 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() @@ -189,7 +202,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { if let progress = libraryItem.userMediaProgress { let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId }) let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress) - Database.shared.saveLocalMediaProgress(localMediaProgress) + try? localMediaProgress.save() statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() } @@ -276,7 +289,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } // Persist in the database before status start coming in - Database.shared.saveDownloadItem(downloadItem) + try Database.shared.saveDownloadItem(downloadItem) // Start all the downloads for task in tasks { diff --git a/ios/App/App/plugins/AbsFileSystem.swift b/ios/App/App/plugins/AbsFileSystem.swift index 928eed64..a96e53c6 100644 --- a/ios/App/App/plugins/AbsFileSystem.swift +++ b/ios/App/App/plugins/AbsFileSystem.swift @@ -70,7 +70,7 @@ public class AbsFileSystem: CAPPlugin { do { if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { try FileManager.default.removeItem(at: item.contentDirectory!) - item.delete() + try item.delete() success = true } } catch { @@ -89,24 +89,29 @@ public class AbsFileSystem: CAPPlugin { var success = false if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { - item.update { - do { - if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) { - try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath) - item.realm?.delete(item.localFiles[fileIndex]) - if item.isPodcast, let media = item.media { - if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) { - media.episodes.remove(at: episodeIndex) + do { + try item.update { + do { + if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) { + try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath) + item.realm?.delete(item.localFiles[fileIndex]) + if item.isPodcast, let media = item.media { + 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()) - success = true + } catch { + NSLog("Failed to delete \(error)") + success = false } - } catch { - NSLog("Failed to delete \(error)") - success = false } + } catch { + NSLog("Failed to delete \(error)") + success = false } } diff --git a/ios/App/Podfile b/ios/App/Podfile index 89125e80..dcdae730 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -9,12 +9,12 @@ install! 'cocoapods', :disable_input_output_paths => true def capacitor_pods pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' - pod 'CapacitorApp', :path => '..\..\node_modules\@capacitor\app' - pod 'CapacitorDialog', :path => '..\..\node_modules\@capacitor\dialog' - pod 'CapacitorHaptics', :path => '..\..\node_modules\@capacitor\haptics' - pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network' - pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar' - pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage' + pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' + pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog' + pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' + pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' + pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' + pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage' end target 'App' do diff --git a/ios/App/Shared/models/PlaybackSession.swift b/ios/App/Shared/models/PlaybackSession.swift index fc562c4e..62db00cf 100644 --- a/ios/App/Shared/models/PlaybackSession.swift +++ b/ios/App/Shared/models/PlaybackSession.swift @@ -31,6 +31,7 @@ class PlaybackSession: Object, Codable, Deletable { @Persisted var serverConnectionConfigId: String? @Persisted var serverAddress: String? @Persisted var isActiveSession = true + @Persisted var serverUpdatedAt: Double = 0 var isLocal: Bool { self.localLibraryItem != nil } var mediaPlayer: String { "AVPlayer" } diff --git a/ios/App/Shared/models/download/DownloadItem.swift b/ios/App/Shared/models/download/DownloadItem.swift index 446cadcc..ee30213a 100644 --- a/ios/App/Shared/models/download/DownloadItem.swift +++ b/ios/App/Shared/models/download/DownloadItem.swift @@ -88,8 +88,8 @@ extension DownloadItem { self.downloadItemParts.allSatisfy({ $0.failed == false }) } - func delete() { - try! self.realm?.write { + func delete() throws { + try self.realm?.write { self.realm?.delete(self.downloadItemParts) self.realm?.delete(self) } diff --git a/ios/App/Shared/models/local/LocalLibraryItem.swift b/ios/App/Shared/models/local/LocalLibraryItem.swift index d2ef503c..51d6a8bb 100644 --- a/ios/App/Shared/models/local/LocalLibraryItem.swift +++ b/ios/App/Shared/models/local/LocalLibraryItem.swift @@ -198,8 +198,8 @@ extension LocalLibraryItem { ) } - func delete() { - try! self.realm?.write { + func delete() throws { + try self.realm?.write { self.realm?.delete(self.localFiles) self.realm?.delete(self) } diff --git a/ios/App/Shared/models/local/LocalMediaProgress.swift b/ios/App/Shared/models/local/LocalMediaProgress.swift index 8962673d..fc93198b 100644 --- a/ios/App/Shared/models/local/LocalMediaProgress.swift +++ b/ios/App/Shared/models/local/LocalMediaProgress.swift @@ -63,8 +63,12 @@ class LocalMediaProgress: Object, Codable { try container.encode(localLibraryItemId, forKey: .localLibraryItemId) try container.encode(localEpisodeId, forKey: .localEpisodeId) try container.encode(duration, forKey: .duration) - try container.encode(progress, forKey: .progress) - try container.encode(currentTime, forKey: .currentTime) + if progress.isNaN == false { + 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(lastUpdate, forKey: .lastUpdate) try container.encode(startedAt, forKey: .startedAt) @@ -115,8 +119,8 @@ extension LocalMediaProgress { self.finishedAt = progress.finishedAt } - func updateIsFinished(_ finished: Bool) { - try! Realm().write { + func updateIsFinished(_ finished: Bool) throws { + try self.realm?.write { if self.isFinished != finished { self.progress = finished ? 1.0 : 0.0 } @@ -131,8 +135,8 @@ extension LocalMediaProgress { } } - func updateFromPlaybackSession(_ playbackSession: PlaybackSession) { - try! Realm().write { + func updateFromPlaybackSession(_ playbackSession: PlaybackSession) throws { + try self.realm?.write { self.currentTime = playbackSession.currentTime self.progress = playbackSession.progress self.lastUpdate = Date().timeIntervalSince1970 * 1000 @@ -141,8 +145,8 @@ extension LocalMediaProgress { } } - func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) { - try! Realm().write { + func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws { + try self.realm?.write { self.isFinished = serverMediaProgress.isFinished self.progress = serverMediaProgress.progress self.currentTime = serverMediaProgress.currentTime @@ -153,21 +157,38 @@ extension LocalMediaProgress { } } - static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) -> LocalMediaProgress? { - 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) - return LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode) + static func getLocalMediaProgressId(localLibraryItemId: String?, localEpisodeId: String?) -> String? { + if let itemId = localLibraryItemId, let episodeId = localEpisodeId { + return "\(itemId)-\(episodeId)" + } else if let itemId = localLibraryItemId { + return itemId } else { 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 + } + } + } } diff --git a/ios/App/Shared/models/server/AudioTrack.swift b/ios/App/Shared/models/server/AudioTrack.swift index c0d0ab88..f0e8f720 100644 --- a/ios/App/Shared/models/server/AudioTrack.swift +++ b/ios/App/Shared/models/server/AudioTrack.swift @@ -19,6 +19,13 @@ class AudioTrack: EmbeddedObject, Codable { @Persisted var localFileId: String? @Persisted var serverIndex: Int? + var endOffset: Double? { + if let startOffset = startOffset { + return startOffset + duration + } + return nil + } + private enum CodingKeys : String, CodingKey { 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) mimeType = try values.decode(String.self, forKey: .mimeType) 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) } diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index 6a16c494..1d599d91 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -18,12 +18,13 @@ enum PlayMethod:Int { } class AudioPlayer: NSObject { + internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue") + // enums and @objc are not compatible @objc dynamic var status: Int @objc dynamic var rate: Float private var tmpRate: Float = 1.0 - private var lastPlayTime: Double = 0.0 private var playerContext = 0 private var playerItemContext = 0 @@ -31,15 +32,24 @@ class AudioPlayer: NSObject { private var playWhenReady: Bool private var initialPlaybackRate: Float - private var audioPlayer: AVQueuePlayer + internal var audioPlayer: AVQueuePlayer private var sessionId: String + private var timeObserverToken: Any? private var queueObserver: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 pausedTimer: Timer? + // MARK: - Constructor init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) { self.playWhenReady = playWhenReady @@ -56,15 +66,23 @@ class AudioPlayer: NSObject { initAudioSession() 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 + self.setupAudioSessionNotifications() self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) for track in playbackSession.audioTracks { - let playerItem = AVPlayerItem(asset: createAsset(itemId: playbackSession.libraryItemId!, track: track)) - self.allPlayerItems.append(playerItem) + if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) { + let playerItem = AVPlayerItem(asset: playerAsset) + self.allPlayerItems.append(playerItem) + } } self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime) @@ -77,22 +95,27 @@ class AudioPlayer: NSObject { self.audioPlayer.insert(item, after:self.audioPlayer.items().last) } + setupTimeObserver() setupQueueObserver() setupQueueItemStatusObserver() NSLog("Audioplayer ready") } + deinit { + self.stopPausedTimer() + self.removeSleepTimer() + self.removeTimeObserver() self.queueObserver?.invalidate() self.queueItemStatusObserver?.invalidate() - destroy() } + public func destroy() { // 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 // It is related to L79 `AVAudioSession.sharedInstance().setActive(false)` - pause() - audioPlayer.replaceCurrentItem(with: nil) + self.pause() + self.audioPlayer.replaceCurrentItem(with: nil) do { try AVAudioSession.sharedInstance().setActive(false) @@ -101,18 +124,28 @@ class AudioPlayer: NSObject { print(error) } + self.removeAudioSessionNotifications() DispatchQueue.runOnMainQueue { 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) } - func isInitialized() -> Bool { + public func isInitialized() -> Bool { return self.status != -1 } - func getItemIndexForTime(time:Double) -> Int { - let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! + public func getPlaybackSession() -> PlaybackSession? { + 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.. 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 self.audioPlayer.currentItem.map { item in 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 = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in - let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! - 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) - } + self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { [weak self] playerItem, change in + self?.handleQueueItemStatus(playerItem: playerItem) }) + + // 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 public func play(allowSeekBack: Bool = false) { guard self.isInitialized() else { return } - - if allowSeekBack { - let diffrence = Date.timeIntervalSinceReferenceDate - lastPlayTime - 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") - } + guard let session = self.getPlaybackSession() else { + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) + return } - 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() { guard self.isInitialized() else { return } + NSLog("PAUSE: Pausing playback") 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.rate = 0.0 updateNowPlaying() - lastPlayTime = Date.timeIntervalSinceReferenceDate + + self.startPausedTimer() } public func seek(_ to: Double, from: String) { 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 ctso = currentTrack.startOffset ?? 0.0 let trackEnd = ctso + currentTrack.duration - NSLog("Seek current track END = \(trackEnd)") - + NSLog("SEEK: Seek current track END = \(trackEnd)") 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 if (self.currentTrackIndex != indexOfSeek) { self.currentTrackIndex = indexOfSeek - playbackSession.update { + try? playbackSession.update { playbackSession.currentTime = to } @@ -251,59 +401,72 @@ class AudioPlayer: NSObject { setupQueueItemStatusObserver() } else { - NSLog("Seeking in current item \(to)") + NSLog("SEEK: Seeking in current item \(to)") let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 let seekTime = to - currentTrackStartOffset - self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in - if !completed { - NSLog("WARNING: seeking not completed (to \(seekTime)") - } + self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in + guard completed else { return NSLog("SEEK: WARNING: seeking not completed (to \(seekTime)") } + guard let self = self else { return } if continuePlaying { - self.play() + self.resumePlayback() } + self.updateNowPlaying() } } } public func setPlaybackRate(_ rate: Float, observed: Bool = false) { + let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1) + if self.audioPlayer.rate != rate { NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") self.audioPlayer.rate = rate } - if rate > 0.0 && !(observed && rate == 1) { - self.tmpRate = rate - } self.rate = rate self.updateNowPlaying() + + if playbackSpeedChanged { + self.tmpRate = rate + + // Setup the time observer again at the new rate + self.setupTimeObserver() + } } - public func getCurrentTime() -> Double { - let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! + public func getCurrentTime() -> Double? { + guard let playbackSession = self.getPlaybackSession() else { return nil } let currentTrackTime = self.audioPlayer.currentTime().seconds let audioTrack = playbackSession.audioTracks[currentTrackIndex] let startOffset = audioTrack.startOffset ?? 0.0 return startOffset + currentTrackTime } - public func getPlayMethod() -> Int { - let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! + public func getPlayMethod() -> Int? { + guard let playbackSession = self.getPlaybackSession() else { return nil } return playbackSession.playMethod } + public func getPlaybackSessionId() -> String { 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 } + public func isPlaying() -> Bool { + return self.status > 0 + } + // MARK: - Private - private func createAsset(itemId:String, track:AudioTrack) -> AVAsset { - let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)! + private func createAsset(itemId:String, track:AudioTrack) -> AVAsset? { + guard let playbackSession = self.getPlaybackSession() else { return nil } + if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { // The only reason this is separate is because the filename needs to be encoded let filename = track.metadata?.filename ?? "" @@ -332,77 +495,142 @@ class AudioPlayer: NSObject { private func initAudioSession() { do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay]) - try AVAudioSession.sharedInstance().setActive(true) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) } catch { NSLog("Failed to set AVAudioSession category") 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 private func setupRemoteTransportControls() { DispatchQueue.runOnMainQueue { UIApplication.shared.beginReceivingRemoteControlEvents() } let commandCenter = MPRemoteCommandCenter.shared() + let deviceSettings = Database.shared.getDeviceSettings() commandCenter.playCommand.isEnabled = true - commandCenter.playCommand.addTarget { [unowned self] event in - play(allowSeekBack: true) + commandCenter.playCommand.addTarget { [weak self] event in + self?.play(allowSeekBack: true) return .success } commandCenter.pauseCommand.isEnabled = true - commandCenter.pauseCommand.addTarget { [unowned self] event in - pause() + commandCenter.pauseCommand.addTarget { [weak self] event in + self?.pause() return .success } commandCenter.skipForwardCommand.isEnabled = true - commandCenter.skipForwardCommand.preferredIntervals = [30] - commandCenter.skipForwardCommand.addTarget { [unowned self] event in + commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)] + commandCenter.skipForwardCommand.addTarget { [weak self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent } - - seek(getCurrentTime() + command.preferredIntervals[0].doubleValue, from: "remote") + guard let currentTime = self?.getCurrentTime() else { + return .commandFailed + } + self?.seek(currentTime + command.preferredIntervals[0].doubleValue, from: "remote") return .success } commandCenter.skipBackwardCommand.isEnabled = true - commandCenter.skipBackwardCommand.preferredIntervals = [30] - commandCenter.skipBackwardCommand.addTarget { [unowned self] event in + commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)] + commandCenter.skipBackwardCommand.addTarget { [weak self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent } - - seek(getCurrentTime() - command.preferredIntervals[0].doubleValue, from: "remote") + guard let currentTime = self?.getCurrentTime() else { + return .commandFailed + } + self?.seek(currentTime - command.preferredIntervals[0].doubleValue, from: "remote") return .success } commandCenter.changePlaybackPositionCommand.isEnabled = true - commandCenter.changePlaybackPositionCommand.addTarget { event in + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .noSuchContent } - self.seek(event.positionTime, from: "remote") + self?.seek(event.positionTime, from: "remote") return .success } commandCenter.changePlaybackRateCommand.isEnabled = true 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 { return .noSuchContent } - self.setPlaybackRate(event.playbackRate) + self?.setPlaybackRate(event.playbackRate) return .success } } private func updateNowPlaying() { 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 diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift new file mode 100644 index 00000000..b5995bc9 --- /dev/null +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -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 + } + +} diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index 6d840d54..975b5331 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -10,85 +10,6 @@ import RealmSwift class PlayerHandler { 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) { guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return } @@ -99,26 +20,21 @@ class PlayerHandler { player = nil } - // Cleanup old sessions + // Cleanup and sync old sessions cleanupOldSessions(currentSessionId: sessionId) + Task { await PlayerProgress.shared.syncToServer() } // 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)) // Create the audio player player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate) - - startTickTimer() - startPausedTimer() } public static func stopPlayback() { // Pause playback first, so we can sync our current progress player?.pause() - // Stop updating progress before we destory the player, so we don't receive bad data - stopTickTimer() - player?.destroy() player = nil @@ -127,6 +43,20 @@ class PlayerHandler { 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? { self.player?.getCurrentTime() } @@ -135,46 +65,70 @@ class PlayerHandler { 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? { self.player?.getPlayMethod() } public static func getPlaybackSession() -> PlaybackSession? { guard let player = player else { return nil } - guard let session = Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) else { return nil } - return session + guard player.isInitialized() else { return nil } + + return Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) } public static func seekForward(amount: Double) { - guard let player = player else { - return - } + guard let player = player else { 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") } public static func seekBackward(amount: Double) { - guard let player = player else { - return - } + guard let player = player else { 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") } 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]? { guard let player = player else { return nil } guard player.isInitialized() else { return nil } - DispatchQueue.main.async { - syncPlayerProgress() - } - return [ "duration": player.getDuration(), "currentTime": player.getCurrentTime(), @@ -183,68 +137,24 @@ class PlayerHandler { ] } - private static func tick() { - if !paused { - listeningTimePassedSinceLastSync += 1 - - if remainingSleepTime != nil { - if sleepTimerChapterStopTime != nil { - let timeUntilChapterEnd = Double(sleepTimerChapterStopTime ?? 0) - (getCurrentTime() ?? 0) - if timeUntilChapterEnd <= 0 { - paused = true - remainingSleepTime = nil - } else { - remainingSleepTime = Int(timeUntilChapterEnd.rounded()) + // MARK: - Helper logic + + private static func cleanupOldSessions(currentSessionId: String?) { + do { + let realm = try Realm() + let oldSessions = realm.objects(PlaybackSession.self) .where({ + $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id + }) + try realm.write { + for s in oldSessions { + if s.id != currentSessionId { + 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() } } } diff --git a/ios/App/Shared/player/PlayerProgress.swift b/ios/App/Shared/player/PlayerProgress.swift index 1b50f2a4..c39cce8d 100644 --- a/ios/App/Shared/player/PlayerProgress.swift +++ b/ios/App/Shared/player/PlayerProgress.swift @@ -10,38 +10,91 @@ import UIKit import RealmSwift class PlayerProgress { + public static let shared = PlayerProgress() + + private static let TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 10.0 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") - updateAllServerSessionFromLocalSession() + do { + try await updateAllServerSessionFromLocalSession() + } catch { + debugPrint("Failed to syncToServer") + debugPrint(error) + } await UIApplication.shared.endBackgroundTask(backgroundToken) } - public static func syncFromServer() async { + public func syncFromServer() async { 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) } - 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 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 { // Local media progress should have been created // If we're here, it means a library id is invalid return } - localMediaProgress.updateFromPlaybackSession(session) - Database.shared.saveLocalMediaProgress(localMediaProgress) + try localMediaProgress.updateFromPlaybackSession(session) NSLog("Local progress saved to the database") @@ -49,16 +102,46 @@ class PlayerProgress { NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil) } - private static func updateAllServerSessionFromLocalSession() { - let sessions = try! Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) - for session in sessions { - let session = session.freeze() - Task { await updateServerSessionFromLocalSession(session) } + private func updateAllServerSessionFromLocalSession() async throws { + try await withThrowingTaskGroup(of: Void.self) { [self] group in + for session in try await Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) { + let session = session.freeze() + group.addTask { + try await self.updateServerSessionFromLocalSession(session) + } + } + try await group.waitForAll() } } - private static func updateServerSessionFromLocalSession(_ session: PlaybackSession) async { - NSLog("Sending sessionId(\(session.id)) to server") + private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws { + 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 if session.isLocal { @@ -68,16 +151,20 @@ class PlayerProgress { success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id) } + // Remove old sessions after they synced with the server if success && !session.isActiveSession { - NSLog("Deleting sessionId(\(session.id)) as is no longer active") - session.thaw()?.delete() + if let session = session.thaw() { + try session.delete() + } } } - private static func updateLocalSessionFromServerMediaProgress() async { + private func updateLocalSessionFromServerMediaProgress() async throws { 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") return } @@ -105,7 +192,7 @@ class PlayerProgress { if serverIsNewerThanLocal && currentTimeIsDifferent { NSLog("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)") guard let session = session.thaw() else { return } - session.update { + try session.update { session.currentTime = serverCurrentTime session.updatedAt = serverLastUpdate } diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index 2d5258b0..2c247e35 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -200,7 +200,12 @@ class ApiClient { if let updates = response.localProgressUpdates { for update in updates { - Database.shared.saveLocalMediaProgress(update) + do { + try update.save() + } catch { + debugPrint("Failed to update local media progress") + debugPrint(error) + } } } diff --git a/ios/App/Shared/util/DaoExtensions.swift b/ios/App/Shared/util/DaoExtensions.swift index f1d61c3a..67c7923a 100644 --- a/ios/App/Shared/util/DaoExtensions.swift +++ b/ios/App/Shared/util/DaoExtensions.swift @@ -9,15 +9,15 @@ import Foundation import RealmSwift extension Object { - func save() { - let realm = try! Realm() - try! realm.write { + func save() throws { + let realm = try Realm() + try realm.write { realm.add(self, update: .modified) } } - func update(handler: () -> Void) { - try! self.realm?.write { + func update(handler: () -> Void) throws { + try self.realm?.write { handler() } } @@ -33,12 +33,12 @@ extension EmbeddedObject { } protocol Deletable { - func delete() + func delete() throws } extension Deletable where Self: Object { - func delete() { - try! self.realm?.write { + func delete() throws { + try self.realm?.write { self.realm?.delete(self) } } diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 396682ae..8fd116b8 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -20,29 +20,43 @@ class Database { let realm = try! Realm() let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) - 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 { - if existing != nil { - realm.delete(existing!) + if let existing = existing { + do { + try existing.update { + existing.name = config.name + existing.address = config.address + existing.userId = config.userId + existing.username = config.username + existing.token = config.token } - realm.add(config) + } catch { + NSLog("failed to update server config") + debugPrint(error) } - } catch(let exception) { - NSLog("failed to save server config") - debugPrint(exception) + + setLastActiveConfigIndex(index: existing.index) + } 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) { @@ -112,48 +126,83 @@ class Database { } public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] { - let realm = try! Realm() - return Array(realm.objects(LocalLibraryItem.self)) + do { + let realm = try Realm() + return Array(realm.objects(LocalLibraryItem.self)) + } catch { + debugPrint(error) + return [] + } } public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? { - let realm = try! Realm() - return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId }) + do { + 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? { - let realm = try! Realm() - return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId) + do { + let realm = try Realm() + return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId) + } catch { + debugPrint(error) + return nil + } } - public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { - let realm = try! Realm() - try! realm.write { realm.add(localLibraryItem, update: .modified) } + public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) throws { + let realm = try Realm() + try realm.write { realm.add(localLibraryItem, update: .modified) } } public func getLocalFile(localFileId: String) -> LocalFile? { - let realm = try! Realm() - return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId) + do { + let realm = try Realm() + return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId) + } catch { + debugPrint(error) + return nil + } } public func getDownloadItem(downloadItemId: String) -> DownloadItem? { - let realm = try! Realm() - return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) + do { + let realm = try Realm() + return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) + } catch { + debugPrint(error) + return nil + } } public func getDownloadItem(libraryItemId: String) -> DownloadItem? { - let realm = try! Realm() - return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first + do { + let realm = try Realm() + return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first + } catch { + debugPrint(error) + return nil + } } public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? { - let realm = try! Realm() - return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first + do { + 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) { - let realm = try! Realm() - return try! realm.write { realm.add(downloadItem, update: .modified) } + public func saveDownloadItem(_ downloadItem: DownloadItem) throws { + let realm = try Realm() + return try realm.write { realm.add(downloadItem, update: .modified) } } public func getDeviceSettings() -> DeviceSettings { @@ -162,31 +211,41 @@ class Database { } public func getAllLocalMediaProgress() -> [LocalMediaProgress] { - let realm = try! Realm() - return Array(realm.objects(LocalMediaProgress.self)) - } - - public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { - let realm = try! Realm() - try! realm.write { realm.add(mediaProgress, update: .modified) } + do { + let realm = try Realm() + return Array(realm.objects(LocalMediaProgress.self)) + } catch { + debugPrint(error) + return [] + } } // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? { - let realm = try! Realm() - return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) + do { + let realm = try Realm() + return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) + } catch { + debugPrint(error) + return nil + } } - public func removeLocalMediaProgress(localMediaProgressId: String) { - let realm = try! Realm() - try! realm.write { + public func removeLocalMediaProgress(localMediaProgressId: String) throws { + let realm = try Realm() + try realm.write { let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) realm.delete(progress!) } } public func getPlaybackSession(id: String) -> PlaybackSession? { - let realm = try! Realm() - return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id) + do { + let realm = try Realm() + return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id) + } catch { + debugPrint(error) + return nil + } } } diff --git a/ios/App/Shared/util/NowPlayingInfo.swift b/ios/App/Shared/util/NowPlayingInfo.swift index 01e6d386..387c8911 100644 --- a/ios/App/Shared/util/NowPlayingInfo.swift +++ b/ios/App/Shared/util/NowPlayingInfo.swift @@ -59,12 +59,17 @@ class NowPlayingInfo { } } public func update(duration: Double, currentTime: Double, rate: Float) { - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate - nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + // Update on the main to prevent access collisions + DispatchQueue.main.async { [weak self] in + if let self = self { + self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration + self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime + self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate + self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 + + MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo + } + } } public func reset() { diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index 56fe7ad1..6073f2d7 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -9,10 +9,17 @@ import Foundation import RealmSwift class Store { - private static var _serverConfig: ServerConnectionConfig? public static var serverConfig: ServerConnectionConfig? { 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) { if updated != nil { @@ -20,9 +27,6 @@ class Store { } else { Database.shared.setLastActiveConfigIndexToNil() } - - // Make safe for accessing on all threads - _serverConfig = updated?.freeze() } } } diff --git a/package-lock.json b/package-lock.json index 8f27f60c..0fcc0323 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-app", - "version": "0.9.56-beta", + "version": "0.9.58-beta", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf-app", - "version": "0.9.56-beta", + "version": "0.9.57-beta", "dependencies": { "@capacitor/android": "^3.4.3", "@capacitor/app": "^1.1.1", @@ -32982,4 +32982,4 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index d4d7777f..d6949401 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-app", - "version": "0.9.56-beta", + "version": "0.9.58-beta", "author": "advplyr", "scripts": { "dev": "nuxt --hostname 0.0.0.0 --port 1337", diff --git a/pages/item/_id.vue b/pages/item/_id.vue index 53e886f6..3a7a4b94 100644 --- a/pages/item/_id.vue +++ b/pages/item/_id.vue @@ -583,13 +583,20 @@ export default { this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) }) } + }, + libraryChanged(libraryId) { + if (this.libraryItem.libraryId !== libraryId) { + this.$router.replace('/bookshelf') + } } }, mounted() { + this.$eventBus.$on('library-changed', this.libraryChanged) this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem) this.$socket.on('item_updated', this.itemUpdated) }, beforeDestroy() { + this.$eventBus.$off('library-changed', this.libraryChanged) this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem) this.$socket.off('item_updated', this.itemUpdated) } diff --git a/plugins/localStore.js b/plugins/localStore.js index b88e3411..b0ee0094 100644 --- a/plugins/localStore.js +++ b/plugins/localStore.js @@ -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) { try { await Storage.set({ key: 'bookshelfListView', value: useIt ? '1' : '0' }) diff --git a/store/libraries.js b/store/libraries.js index 08b62fa9..af868897 100644 --- a/store/libraries.js +++ b/store/libraries.js @@ -22,7 +22,7 @@ export const getters = { } export const actions = { - fetch({ state, commit, rootState }, libraryId) { + fetch({ state, commit, dispatch, rootState }, libraryId) { if (!rootState.user || !rootState.user.user) { console.error('libraries/fetch - User not set') return false @@ -35,6 +35,8 @@ export const actions = { var filterData = data.filterdata var issues = data.issues || 0 + dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) + commit('addUpdate', library) commit('setLibraryIssues', issues) commit('setLibraryFilterData', filterData) diff --git a/store/user.js b/store/user.js index 4f76dfb2..7ae96ebd 100644 --- a/store/user.js +++ b/store/user.js @@ -48,6 +48,36 @@ export const getters = { } 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) { if (state.serverConnectionConfig) { var updatePayload = {