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