mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-31 15:19:34 +02:00
Merge branch 'advplyr:master' into vangorra/aa-button-cleanup
This commit is contained in:
commit
92f10fdb5c
42 changed files with 1465 additions and 814 deletions
|
@ -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.
|
||||
|
|
|
@ -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<LibraryFile>?,
|
||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||
) : LibraryItemWrapper() {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@get:JsonIgnore
|
||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri():Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun checkHasTracks():Boolean {
|
||||
return if (mediaType == "podcast") {
|
||||
((media as Podcast).numEpisodes ?: 0) > 0
|
||||
} else {
|
||||
((media as Book).numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
// This auto-detects whether it is a Book or Podcast
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
Used in Android Auto to represent a podcast episode or an audiobook in progress
|
||||
*/
|
||||
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.json.JSONObject
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ItemInProgress(
|
||||
val libraryItemWrapper: LibraryItemWrapper,
|
||||
val episode: PodcastEpisode?,
|
||||
val progressLastUpdate: Long,
|
||||
val isLocal: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||
|
||||
var episode:PodcastEpisode? = null
|
||||
if (serverItem.has("recentEpisode")) {
|
||||
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
|
||||
}
|
||||
|
||||
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
|
||||
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class LibraryItem(
|
||||
id:String,
|
||||
var ino:String,
|
||||
var libraryId:String,
|
||||
var folderId:String,
|
||||
var path:String,
|
||||
var relPath:String,
|
||||
var mtimeMs:Long,
|
||||
var ctimeMs:Long,
|
||||
var birthtimeMs:Long,
|
||||
var addedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var lastScan:Long?,
|
||||
var scanVersion:String?,
|
||||
var isMissing:Boolean,
|
||||
var isInvalid:Boolean,
|
||||
var mediaType:String,
|
||||
var media:MediaType,
|
||||
var libraryFiles:MutableList<LibraryFile>?,
|
||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@get:JsonIgnore
|
||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun checkHasTracks():Boolean {
|
||||
return if (mediaType == "podcast") {
|
||||
((media as Podcast).numEpisodes ?: 0) > 0
|
||||
} else {
|
||||
((media as Book).numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(getCoverUri())
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -1,10 +1,8 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<LibraryItem>()
|
||||
var selectedLibraryId = ""
|
||||
|
||||
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
|
||||
var selectedPodcast:Podcast? = null
|
||||
var selectedLibraryItemId:String? = null
|
||||
var serverPodcastEpisodes = listOf<PodcastEpisode>()
|
||||
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
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<LibraryCategory>) -> Unit) {
|
||||
if (serverLibraryCategories.isNotEmpty()) {
|
||||
cb(serverLibraryCategories)
|
||||
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> 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<LibraryCategory> {
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||
val cats = mutableListOf<LibraryCategory>()
|
||||
if (localBooks.isNotEmpty()) {
|
||||
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
|
||||
}
|
||||
if (localPodcasts.isNotEmpty()) {
|
||||
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
|
||||
}
|
||||
return cats
|
||||
}
|
||||
|
||||
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
val cats = mutableListOf<LibraryCategory>()
|
||||
|
||||
val localCategories = loadLocalCategory()
|
||||
cats.addAll(localCategories)
|
||||
|
||||
// Check if any valid server connection if not use locally downloaded books
|
||||
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
|
||||
|
|
|
@ -10,7 +10,7 @@ import com.audiobookshelf.app.data.*
|
|||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
libraryCategories: List<LibraryCategory>,
|
||||
itemsInProgress: List<ItemInProgress>,
|
||||
libraries: List<Library>
|
||||
) {
|
||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||
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<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
} 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<MediaBrowserCompat.MediaItem> = 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<MediaBrowserCompat.MediaItem> = 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<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
|
@ -919,10 +932,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
|
||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||
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<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
|
|
|
@ -189,21 +189,16 @@ class ApiHandler(var ctx:Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
||||
getRequest("/api/libraries/$libraryId/personalized", null, null) {
|
||||
val items = mutableListOf<LibraryCategory>()
|
||||
if (it.has("value")) {
|
||||
val array = it.getJSONArray("value")
|
||||
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||
getRequest("/api/me/items-in-progress", null, null) {
|
||||
val items = mutableListOf<ItemInProgress>()
|
||||
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<LibraryCategory>(jsobj.toString())
|
||||
items.add(item)
|
||||
}
|
||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||
items.add(itemInProgress)
|
||||
}
|
||||
}
|
||||
cb(items)
|
||||
|
|
|
@ -8,9 +8,7 @@
|
|||
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||
</div>
|
||||
<div class="top-4 right-4 absolute cursor-pointer">
|
||||
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction">
|
||||
<span class="material-icons text-3xl">more_vert</span>
|
||||
</ui-dropdown-menu>
|
||||
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
</div>
|
||||
|
@ -66,23 +64,24 @@
|
|||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
||||
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
|
||||
</div>
|
||||
<div id="timestamp-row" class="flex pt-0.5">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
|
@ -95,6 +94,24 @@
|
|||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
|
||||
<template v-slot:chapter_track="{ item }">
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||
<div class="flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<template v-slot:lock="{ item }">
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||
<div class="flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</modals-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -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')
|
||||
|
|
|
@ -10,11 +10,13 @@
|
|||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</slot>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<div v-if="book" class="flex w-full">
|
||||
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
|
||||
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link>
|
||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = "<group>"; };
|
||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; };
|
||||
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSleepTimer.swift; sourceTree = "<group>"; };
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
@ -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 = "";
|
||||
|
|
|
@ -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<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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..<self.allPlayerItems.count {
|
||||
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
|
||||
let duration = playbackSession.audioTracks[index].duration
|
||||
|
@ -124,8 +157,59 @@ class AudioPlayer: NSObject {
|
|||
return 0
|
||||
}
|
||||
|
||||
func setupQueueObserver() {
|
||||
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in
|
||||
private func setupAudioSessionNotifications() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleInteruption), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
|
||||
}
|
||||
|
||||
private func removeAudioSessionNotifications() {
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
|
||||
}
|
||||
|
||||
private func setupTimeObserver() {
|
||||
// Time observer should be configured on the main queue
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.removeTimeObserver()
|
||||
|
||||
let timeScale = CMTimeScale(NSEC_PER_SEC)
|
||||
// Rate will be different depending on playback speed, aim for 2 observations/sec
|
||||
let seconds = 0.5 * (self.rate > 0 ? self.rate : 1.0)
|
||||
let time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
|
||||
self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard let currentTime = self.getCurrentTime() else { return }
|
||||
let isPlaying = self.isPlaying()
|
||||
|
||||
Task {
|
||||
// Let the player update the current playback positions
|
||||
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: isPlaying, isStopping: false)
|
||||
}
|
||||
|
||||
if self.isSleepTimerSet() {
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
|
||||
// Handle a sitation where the user skips past the chapter end
|
||||
if self.isChapterSleepTimerBeforeTime(currentTime) {
|
||||
self.removeSleepTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTimeObserver() {
|
||||
if let timeObserverToken = timeObserverToken {
|
||||
self.audioPlayer.removeTimeObserver(timeObserverToken)
|
||||
self.timeObserverToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setupQueueObserver() {
|
||||
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) { [weak self] _,_ in
|
||||
guard let self = self else { return }
|
||||
let prevTrackIndex = self.currentTrackIndex
|
||||
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
|
||||
|
|
156
ios/App/Shared/player/AudioPlayerSleepTimer.swift
Normal file
156
ios/App/Shared/player/AudioPlayerSleepTimer.swift
Normal file
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
6
package-lock.json
generated
6
package-lock.json
generated
|
@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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' })
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue