mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-09 13:28:43 +02:00
Compare commits
202
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9821ddabcf | ||
|
|
57777f2f4b | ||
|
|
1f2c309d8c | ||
|
|
c11977870d | ||
|
|
41753cd853 | ||
|
|
003285f567 | ||
|
|
d1c1902cd3 | ||
|
|
b697deac1c | ||
|
|
fb8e6408bb | ||
|
|
836ffddd4f | ||
|
|
c8ff5a7817 | ||
|
|
c14f6ec4c2 | ||
|
|
dc8852eb0d | ||
|
|
586405f9de | ||
|
|
34e68593bc | ||
|
|
c02adfa326 | ||
|
|
021d63d973 | ||
|
|
50f6b15d23 | ||
|
|
6257c6488b | ||
|
|
defd895995 | ||
|
|
82159ee537 | ||
|
|
11f22888d5 | ||
|
|
20d932877e | ||
|
|
35631fb211 | ||
|
|
b02dd18e0b | ||
|
|
b583a8e6e2 | ||
|
|
5775a51731 | ||
|
|
9b94a11ab9 | ||
|
|
9ee1203130 | ||
|
|
711b079963 | ||
|
|
91dbd73d45 | ||
|
|
a2ac0e29e4 | ||
|
|
4271d832f1 | ||
|
|
64147d065c | ||
|
|
fb03c8e67f | ||
|
|
75979960ab | ||
|
|
1411157bde | ||
|
|
eb7289c150 | ||
|
|
c1f803bdd0 | ||
|
|
ba1efedd79 | ||
|
|
ac10997ed7 | ||
|
|
3c6f29bf3a | ||
|
|
f623393f8d | ||
|
|
66ab402a50 | ||
|
|
2076b93e19 | ||
|
|
a5aae3fce8 | ||
|
|
eb7a241e94 | ||
|
|
14ff9097c7 | ||
|
|
79f09b334c | ||
|
|
f5d1e992ef | ||
|
|
aed2c31f5a | ||
|
|
3e31e72734 | ||
|
|
7cf36d829a | ||
|
|
268cf67576 | ||
|
|
5c76158729 | ||
|
|
8c87b31e56 | ||
|
|
01678f2c91 | ||
|
|
b5e33b1707 | ||
|
|
bba388aefa | ||
|
|
46623d70a3 | ||
|
|
452b25057e | ||
|
|
67a6aec132 | ||
|
|
10ddc1c911 | ||
|
|
2a2ebefeb9 | ||
|
|
d5f39e5cb1 | ||
|
|
099be648bf | ||
|
|
6a885b7241 | ||
|
|
15cdff5aa2 | ||
|
|
8bd0e863ae | ||
|
|
94e261d7bf | ||
|
|
06f87d24a7 | ||
|
|
cdd2f40ef9 | ||
|
|
2448b461f0 | ||
|
|
4f85827585 | ||
|
|
fe042f3f83 | ||
|
|
d084958f2d | ||
|
|
ccecba7a19 | ||
|
|
d57fe44bcc | ||
|
|
a32bd9d74c | ||
|
|
39250b56c0 | ||
|
|
1c85e21e4f | ||
|
|
d331e1f9ab | ||
|
|
d7c414f6c0 | ||
|
|
523f861f77 | ||
|
|
8a0a6f383f | ||
|
|
b8b2d73eab | ||
|
|
683b4e753a | ||
|
|
a3f2b02473 | ||
|
|
befe62f8a0 | ||
|
|
621a493c5b | ||
|
|
8952cbfd20 | ||
|
|
a576eff700 | ||
|
|
506e18a137 | ||
|
|
27d2ed2304 | ||
|
|
a670ba9f95 | ||
|
|
2321312427 | ||
|
|
85382d2bb4 | ||
|
|
7745ccefab | ||
|
|
872931f507 | ||
|
|
f335d8735f | ||
|
|
fca0fd7bbb | ||
|
|
a461ed43de | ||
|
|
0f8672fb92 | ||
|
|
972fbd42ee | ||
|
|
062a217946 | ||
|
|
a59f3bb957 | ||
|
|
93cb27d324 | ||
|
|
b4e70da1f5 | ||
|
|
bf46c46fc0 | ||
|
|
af835f2c43 | ||
|
|
ffd0d4da7d | ||
|
|
e9226671c9 | ||
|
|
5fd3f3c080 | ||
|
|
519969eee0 | ||
|
|
1746ed6469 | ||
|
|
2912d442b5 | ||
|
|
a82474cf45 | ||
|
|
56a3d8bb72 | ||
|
|
add64249cd | ||
|
|
d3172ece9b | ||
|
|
f655f89c49 | ||
|
|
911e46c4f0 | ||
|
|
ec4647e2bc | ||
|
|
10f2da9e90 | ||
|
|
6aa0f2253b | ||
|
|
7c5ee940d3 | ||
|
|
d83e04c47b | ||
|
|
b0905d0270 | ||
|
|
8ce0d9ce56 | ||
|
|
ac2a1ce079 | ||
|
|
5d31499fd6 | ||
|
|
4c8217edf6 | ||
|
|
934a07a5ad | ||
|
|
7aae1edf3c | ||
|
|
7cc35e4b96 | ||
|
|
1f8a8d1ce5 | ||
|
|
e0e2f02e0b | ||
|
|
c029e519e9 | ||
|
|
ef661bba37 | ||
|
|
8d38f3358e | ||
|
|
f4e39ec7ca | ||
|
|
c32c77b963 | ||
|
|
9477860bca | ||
|
|
65fcc45bc6 | ||
|
|
2e6946e1f7 | ||
|
|
64707b0928 | ||
|
|
a107a25e43 | ||
|
|
0a989e7811 | ||
|
|
4e63d43679 | ||
|
|
60cb5f398c | ||
|
|
8edeefc1cd | ||
|
|
02eabb82c1 | ||
|
|
999d434fe9 | ||
|
|
2b07c81e54 | ||
|
|
5f4ff38035 | ||
|
|
e52a5fd585 | ||
|
|
599a8e23cb | ||
|
|
81a4e4ff7b | ||
|
|
7d730dcafb | ||
|
|
446e54cb91 | ||
|
|
a9d7fbc083 | ||
|
|
a3e458fcc4 | ||
|
|
e275aa1699 | ||
|
|
e9961f64a9 | ||
|
|
8e2be4704e | ||
|
|
948cd3068a | ||
|
|
162eb9afad | ||
|
|
30ae98c3de | ||
|
|
b1b10e846d | ||
|
|
ad802c16ea | ||
|
|
849b6303e8 | ||
|
|
5b7fcca800 | ||
|
|
b549528e23 | ||
|
|
5495bcb945 | ||
|
|
7fded5e105 | ||
|
|
dc8bc2fccd | ||
|
|
af2c609405 | ||
|
|
d5d65e244b | ||
|
|
33041608f8 | ||
|
|
528854b285 | ||
|
|
e02edb9f9e | ||
|
|
37fa931672 | ||
|
|
a7424cc428 | ||
|
|
f6c43e479d | ||
|
|
0b46a9c9b1 | ||
|
|
76de92fe1f | ||
|
|
32550a75ec | ||
|
|
9eca03cfd7 | ||
|
|
fec1ec554b | ||
|
|
db7a8cef77 | ||
|
|
2ab9dbc72d | ||
|
|
52d0890032 | ||
|
|
b7725c455b | ||
|
|
175e642081 | ||
|
|
e620f53705 | ||
|
|
f56561b898 | ||
|
|
a29a7669e1 | ||
|
|
29f08a5e5d | ||
|
|
f40da562a6 | ||
|
|
affcdea09b | ||
|
|
16ca7460f4 | ||
|
|
2ca9ce797d |
@@ -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
|
||||||
@@ -37,9 +38,9 @@ data class DeviceSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpBackwardsTime) * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DeviceData(
|
data class DeviceData(
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Message
|
import android.os.Message
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
import com.audiobookshelf.app.data.PodcastEpisode
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -21,7 +19,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
|
|
||||||
private var mediaButtonClickCount: Int = 0
|
private var mediaButtonClickCount: Int = 0
|
||||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
var mediaButtonClickTimeout: Long = 1000 //ms
|
||||||
var seekAmount: Long = 20000 //ms
|
|
||||||
|
|
||||||
override fun onPrepare() {
|
override fun onPrepare() {
|
||||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||||
@@ -75,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.skipToPrevious()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
override fun onSkipToNext() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.skipToNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFastForward() {
|
override fun onFastForward() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRewind() {
|
override fun onRewind() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
@@ -179,10 +176,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
playerNotificationService.closePlayback()
|
playerNotificationService.closePlayback()
|
||||||
@@ -226,22 +223,22 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
super.handleMessage(msg)
|
super.handleMessage(msg)
|
||||||
if (2 == msg.what) {
|
if (2 == msg.what) {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
else if (msg.what >= 3) {
|
else if (msg.what >= 3) {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example Using a custom action in android auto
|
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||||
// override fun onCustomAction(action: String?, extras: Bundle?) {
|
super.onCustomAction(action, extras)
|
||||||
// super.onCustomAction(action, extras)
|
|
||||||
//
|
when (action) {
|
||||||
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) {
|
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||||
//
|
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
|
||||||
|
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||||
+105
-47
@@ -20,7 +20,6 @@ import android.util.Log
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
@@ -30,9 +29,11 @@ import com.audiobookshelf.app.data.DeviceInfo
|
|||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaManager
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
||||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import com.google.android.exoplayer2.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
@@ -42,6 +43,7 @@ import com.google.android.exoplayer2.upstream.*
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
|
||||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||||
|
|
||||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
@@ -294,17 +296,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||||
|
|
||||||
// Example adding custom action with icon in android auto
|
mediaSessionConnector.setCustomActionProviders(
|
||||||
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider {
|
JumpForwardCustomActionProvider(),
|
||||||
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
JumpBackwardCustomActionProvider(),
|
||||||
// }
|
)
|
||||||
// override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
|
||||||
// var icon = R.drawable.exo_icon_rewind
|
|
||||||
// return PlaybackStateCompat.CustomAction.Builder(
|
|
||||||
// "com.audiobookshelf.app.PLAYBACK_RATE", "Playback Rate", icon)
|
|
||||||
// .build()
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
|
|
||||||
mediaSession.setCallback(MediaSessionCallback(this))
|
mediaSession.setCallback(MediaSessionCallback(this))
|
||||||
|
|
||||||
@@ -320,13 +315,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
1000 * 20 // 20s playback rebuffer
|
1000 * 20 // 20s playback rebuffer
|
||||||
).build()
|
).build()
|
||||||
|
|
||||||
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
|
|
||||||
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
|
|
||||||
|
|
||||||
mPlayer = ExoPlayer.Builder(this)
|
mPlayer = ExoPlayer.Builder(this)
|
||||||
.setLoadControl(customLoadControl)
|
.setLoadControl(customLoadControl)
|
||||||
.setSeekBackIncrementMs(seekBackTime)
|
.setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
|
||||||
.setSeekForwardIncrementMs(seekForwardTime)
|
.setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
|
||||||
.build()
|
.build()
|
||||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||||
mPlayer.addListener(PlayerListener(this))
|
mPlayer.addListener(PlayerListener(this))
|
||||||
@@ -701,6 +693,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipToPrevious() {
|
||||||
|
currentPlayer.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipToNext() {
|
||||||
|
currentPlayer.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpForward() {
|
||||||
|
seekForward(deviceSettings.jumpForwardTimeMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpBackward() {
|
||||||
|
seekBackward(deviceSettings.jumpBackwardsTimeMs)
|
||||||
|
}
|
||||||
|
|
||||||
fun seekForward(amount: Long) {
|
fun seekForward(amount: Long) {
|
||||||
seekPlayer(getCurrentTime() + amount)
|
seekPlayer(getCurrentTime() + amount)
|
||||||
}
|
}
|
||||||
@@ -757,6 +765,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
|
|
||||||
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||||
}
|
}
|
||||||
@@ -784,8 +795,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
|
||||||
|
|
||||||
|
|
||||||
@@ -828,8 +840,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
|
||||||
@@ -841,53 +851,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>?)
|
||||||
}
|
}
|
||||||
@@ -896,10 +920,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>?)
|
||||||
}
|
}
|
||||||
@@ -966,5 +990,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
|
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner class JumpBackwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_BACKWARD,
|
||||||
|
getContext().getString(R.string.action_jump_backward),
|
||||||
|
R.drawable.exo_icon_rewind
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class JumpForwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_FORWARD,
|
||||||
|
getContext().getString(R.string.action_jump_forward),
|
||||||
|
R.drawable.exo_icon_fastforward
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -6,4 +6,6 @@
|
|||||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||||
<string name="add_widget">Add widget</string>
|
<string name="add_widget">Add widget</string>
|
||||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
<string name="action_jump_backward">Jump Backward</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.google.gms:google-services:4.3.5'
|
classpath 'com.google.gms:google-services:4.3.10'
|
||||||
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
|
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { Dialog } from '@capacitor/dialog'
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
isReady: false,
|
||||||
|
settingsLoaded: false,
|
||||||
audioPlayerReady: false,
|
audioPlayerReady: false,
|
||||||
stream: null,
|
stream: null,
|
||||||
download: null,
|
download: null,
|
||||||
@@ -151,6 +153,10 @@ export default {
|
|||||||
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
||||||
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings have been loaded (at least once, so it's safe to kickoff onReady)
|
||||||
|
this.settingsLoaded = true
|
||||||
|
this.notifyOnReady()
|
||||||
},
|
},
|
||||||
setListeners() {
|
setListeners() {
|
||||||
// if (!this.$server.socket) {
|
// if (!this.$server.socket) {
|
||||||
@@ -261,6 +267,18 @@ export default {
|
|||||||
onMediaPlayerChanged(data) {
|
onMediaPlayerChanged(data) {
|
||||||
var mediaPlayer = data.value
|
var mediaPlayer = data.value
|
||||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
this.$store.commit('setMediaPlayer', mediaPlayer)
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// The UI is reporting elsewhere we are ready
|
||||||
|
this.isReady = true
|
||||||
|
this.notifyOnReady()
|
||||||
|
},
|
||||||
|
notifyOnReady() {
|
||||||
|
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||||
|
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||||
|
if ( this.settingsLoaded && this.isReady ) {
|
||||||
|
AbsAudioPlayer.onReady()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -273,6 +291,7 @@ export default {
|
|||||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||||
|
|
||||||
this.setListeners()
|
this.setListeners()
|
||||||
|
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||||
@@ -292,6 +311,7 @@ export default {
|
|||||||
// this.$server.socket.off('stream_ready', this.streamReady)
|
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||||
// this.$server.socket.off('stream_reset', this.streamReset)
|
// this.$server.socket.off('stream_reset', this.streamReset)
|
||||||
// }
|
// }
|
||||||
|
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ export default {
|
|||||||
text: 'Local Media',
|
text: 'Local Media',
|
||||||
to: '/localMedia/folders'
|
to: '/localMedia/folders'
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
icon: 'download',
|
||||||
|
iconOutlined: false,
|
||||||
|
text: 'Downloads',
|
||||||
|
to: '/downloads'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- No progress shown for collapsed series in library -->
|
||||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,6 +164,7 @@ export default {
|
|||||||
return this.media.numTracks
|
return this.media.numTracks
|
||||||
},
|
},
|
||||||
numEpisodes() {
|
numEpisodes() {
|
||||||
|
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||||
return this.media.numEpisodes
|
return this.media.numEpisodes
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
@@ -351,6 +352,17 @@ export default {
|
|||||||
if (!this.isAltViewEnabled) return 0
|
if (!this.isAltViewEnabled) return 0
|
||||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
return 4.25 * this.sizeMultiplier
|
return 4.25 * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
showHasLocalDownload() {
|
||||||
|
if (this.localLibraryItem || this.isLocal) {
|
||||||
|
if (this.recentEpisode && !this.isLocal) {
|
||||||
|
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
|
return !!localEpisode
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -399,8 +411,26 @@ export default {
|
|||||||
this.selectBtnClick()
|
this.selectBtnClick()
|
||||||
} else if (this.recentEpisode) {
|
} else if (this.recentEpisode) {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
if (this.streamIsPlaying) eventBus.$emit('pause-item')
|
if (this.streamIsPlaying) {
|
||||||
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
eventBus.$emit('pause-item')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.localLibraryItem) {
|
||||||
|
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
|
if (localEpisode) {
|
||||||
|
// Play episode locally
|
||||||
|
eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.localLibraryItemId,
|
||||||
|
episodeId: localEpisode.id,
|
||||||
|
serverLibraryItemId: this.libraryItemId,
|
||||||
|
serverEpisodeId: this.recentEpisode.id
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||||
} else {
|
} else {
|
||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
if (router) {
|
if (router) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -6,9 +6,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div
|
||||||
|
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||||
|
@click="
|
||||||
|
show = false
|
||||||
|
manualTimerModal = false
|
||||||
|
"
|
||||||
|
>
|
||||||
<div 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 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 v-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<div v-if="manualTimerModal" class="p-4">
|
||||||
|
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||||
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex my-2 justify-between">
|
||||||
|
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||||
|
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||||
|
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||||
|
</div>
|
||||||
|
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="timeout in timeouts">
|
<template v-for="timeout in timeouts">
|
||||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
@@ -21,8 +38,13 @@
|
|||||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal block truncate text-lg text-center">Manual sleep timer</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-else class="px-2 py-4">
|
<div v-else class="p-4">
|
||||||
<div class="flex my-2 justify-between">
|
<div class="flex my-2 justify-between">
|
||||||
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||||
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
||||||
@@ -45,7 +67,10 @@ export default {
|
|||||||
currentEndOfChapterTime: Number
|
currentEndOfChapterTime: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
manualTimerModal: null,
|
||||||
|
manualTimeoutMin: 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
@@ -57,7 +82,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
timeouts() {
|
timeouts() {
|
||||||
return [1, 5, 10, 15, 30, 45, 60, 90]
|
return [5, 10, 15, 30, 45, 60, 90]
|
||||||
},
|
},
|
||||||
timeRemainingPretty() {
|
timeRemainingPretty() {
|
||||||
return this.$secondsToTimestamp(this.currentTime)
|
return this.$secondsToTimestamp(this.currentTime)
|
||||||
@@ -71,6 +96,7 @@ export default {
|
|||||||
clickedOption(timeoutMin) {
|
clickedOption(timeoutMin) {
|
||||||
var timeout = timeoutMin * 1000 * 60
|
var timeout = timeoutMin * 1000 * 60
|
||||||
this.show = false
|
this.show = false
|
||||||
|
this.manualTimerModal = false
|
||||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||||
},
|
},
|
||||||
cancelSleepTimer() {
|
cancelSleepTimer() {
|
||||||
@@ -82,6 +108,12 @@ export default {
|
|||||||
},
|
},
|
||||||
decreaseSleepTime() {
|
decreaseSleepTime() {
|
||||||
this.$emit('decrease')
|
this.$emit('decrease')
|
||||||
|
},
|
||||||
|
increaseManualTimeout() {
|
||||||
|
this.manualTimeoutMin++
|
||||||
|
},
|
||||||
|
decreaseManualTimeout() {
|
||||||
|
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
|
|
||||||
<div v-if="!isIos && userCanDownload">
|
<div v-if="userCanDownload">
|
||||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||||
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||||
@@ -143,7 +143,12 @@ export default {
|
|||||||
},
|
},
|
||||||
downloadClick() {
|
downloadClick() {
|
||||||
if (this.downloadItem) return
|
if (this.downloadItem) return
|
||||||
this.download()
|
if (this.isIos) {
|
||||||
|
// no local folders on iOS
|
||||||
|
this.startDownload()
|
||||||
|
} else {
|
||||||
|
this.download()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
var localFolder = selectedLocalFolder
|
var localFolder = selectedLocalFolder
|
||||||
@@ -183,7 +188,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async startDownload(localFolder) {
|
async startDownload(localFolder) {
|
||||||
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id })
|
var payload = {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: this.episode.id
|
||||||
|
}
|
||||||
|
if (localFolder) {
|
||||||
|
payload.localFolderId = localFolder.id
|
||||||
|
}
|
||||||
|
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
|
||||||
if (downloadRes && downloadRes.error) {
|
if (downloadRes && downloadRes.error) {
|
||||||
var errorMsg = downloadRes.error || 'Unknown error'
|
var errorMsg = downloadRes.error || 'Unknown error'
|
||||||
console.error('Download error', errorMsg)
|
console.error('Download error', errorMsg)
|
||||||
|
|||||||
@@ -35,7 +35,31 @@
|
|||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; };
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||||
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||||
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||||
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */; };
|
||||||
|
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504D28AC1B0700C746DD /* AudioFile.swift */; };
|
||||||
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504F28AC1B3E00C746DD /* Author.swift */; };
|
||||||
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505128AC1B5D00C746DD /* Chapter.swift */; };
|
||||||
|
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505328AC1B7900C746DD /* AudioTrack.swift */; };
|
||||||
|
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505528AC1BFA00C746DD /* FileMetadata.swift */; };
|
||||||
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505728AC1C1A00C746DD /* Library.swift */; };
|
||||||
|
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505928AC1C4500C746DD /* Folder.swift */; };
|
||||||
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505B28AC1C6200C746DD /* LibraryFile.swift */; };
|
||||||
|
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505D28AC1C8500C746DD /* MediaProgress.swift */; };
|
||||||
|
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */; };
|
||||||
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506128AC1CC900C746DD /* PlayerState.swift */; };
|
||||||
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */; };
|
||||||
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */; };
|
||||||
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506928AC1DF100C746DD /* LocalFile.swift */; };
|
||||||
|
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */; };
|
||||||
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */; };
|
||||||
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; };
|
||||||
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; };
|
||||||
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; };
|
||||||
|
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */; };
|
||||||
|
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -71,7 +95,31 @@
|
|||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = "<group>"; };
|
E9D5504528AC1A3900C746DD /* LibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItem.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504728AC1A7A00C746DD /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504928AC1AA600C746DD /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastEpisode.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504D28AC1B0700C746DD /* AudioFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFile.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504F28AC1B3E00C746DD /* Author.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Author.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505128AC1B5D00C746DD /* Chapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chapter.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505328AC1B7900C746DD /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMetadata.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505728AC1C1A00C746DD /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505928AC1C4500C746DD /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFile.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProgress.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackMetadata.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506128AC1CC900C746DD /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryItem.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPodcastEpisode.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506928AC1DF100C746DD /* LocalFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFile.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMediaProgress.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItem.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>"; };
|
||||||
|
E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; };
|
||||||
|
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSleepTimer.swift; sourceTree = "<group>"; };
|
||||||
|
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
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 */
|
||||||
|
|
||||||
@@ -99,7 +147,9 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||||
|
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
||||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
||||||
|
E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
|
||||||
);
|
);
|
||||||
path = player;
|
path = player;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -134,9 +184,14 @@
|
|||||||
children = (
|
children = (
|
||||||
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
||||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
||||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
|
|
||||||
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
||||||
|
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */,
|
||||||
|
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||||
|
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||||
|
E9D5506328AC1D3F00C746DD /* server */,
|
||||||
|
E9D5506428AC1D5800C746DD /* local */,
|
||||||
|
E9D5506D28AC1E7400C746DD /* download */,
|
||||||
);
|
);
|
||||||
path = models;
|
path = models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -150,6 +205,7 @@
|
|||||||
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
||||||
3AB34052280829BF0039308B /* Extensions.swift */,
|
3AB34052280829BF0039308B /* Extensions.swift */,
|
||||||
3AB34054280832720039308B /* PlayerEvents.swift */,
|
3AB34054280832720039308B /* PlayerEvents.swift */,
|
||||||
|
E9D5507228AC218300C746DD /* DaoExtensions.swift */,
|
||||||
);
|
);
|
||||||
path = util;
|
path = util;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -199,6 +255,46 @@
|
|||||||
name = Pods;
|
name = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E9D5506328AC1D3F00C746DD /* server */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5504528AC1A3900C746DD /* LibraryItem.swift */,
|
||||||
|
E9D5504728AC1A7A00C746DD /* MediaType.swift */,
|
||||||
|
E9D5504928AC1AA600C746DD /* Metadata.swift */,
|
||||||
|
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */,
|
||||||
|
E9D5504D28AC1B0700C746DD /* AudioFile.swift */,
|
||||||
|
E9D5504F28AC1B3E00C746DD /* Author.swift */,
|
||||||
|
E9D5505128AC1B5D00C746DD /* Chapter.swift */,
|
||||||
|
E9D5505328AC1B7900C746DD /* AudioTrack.swift */,
|
||||||
|
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */,
|
||||||
|
E9D5505728AC1C1A00C746DD /* Library.swift */,
|
||||||
|
E9D5505928AC1C4500C746DD /* Folder.swift */,
|
||||||
|
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
||||||
|
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
||||||
|
);
|
||||||
|
path = server;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E9D5506428AC1D5800C746DD /* local */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */,
|
||||||
|
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */,
|
||||||
|
E9D5506928AC1DF100C746DD /* LocalFile.swift */,
|
||||||
|
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */,
|
||||||
|
);
|
||||||
|
path = local;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E9D5506D28AC1E7400C746DD /* download */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */,
|
||||||
|
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */,
|
||||||
|
);
|
||||||
|
path = download;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -312,28 +408,52 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
||||||
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||||
|
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||||
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||||
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||||
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
||||||
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
||||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
||||||
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
||||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
||||||
|
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */,
|
||||||
|
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */,
|
||||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||||
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||||
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||||
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||||
|
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||||
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||||
|
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
|
||||||
|
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */,
|
||||||
|
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
|
||||||
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||||
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
||||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
||||||
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
||||||
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
||||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
|
|
||||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
||||||
|
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */,
|
||||||
3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
|
3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
|
||||||
|
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -479,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 = 12.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)";
|
||||||
@@ -503,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 = 12.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 = "";
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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: 1,
|
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
|
||||||
@@ -34,18 +43,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||||
|
NSLog("Audiobookself is now in the background")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||||
|
NSLog("Audiobookself is now in the foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
|
NSLog("Audiobookself is now active")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
|
NSLog("Audiobookself is terminating")
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
@@ -60,6 +73,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// tracking app url opens, make sure to keep this call
|
// tracking app url opens, make sure to keep this call
|
||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||||
|
// Stores the completion handler for background downloads
|
||||||
|
// The identifier of this method can be ignored at this time as we only have one background url session
|
||||||
|
backgroundCompletionHandler = completionHandler
|
||||||
|
}
|
||||||
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
super.touchesBegan(touches, with: event)
|
super.touchesBegan(touches, with: event)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
#import <Capacitor/Capacitor.h>
|
#import <Capacitor/Capacitor.h>
|
||||||
|
|
||||||
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
||||||
|
CAP_PLUGIN_METHOD(onReady, CAPPluginReturnNone);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
@objc(AbsAudioPlayer)
|
@objc(AbsAudioPlayer)
|
||||||
public class AbsAudioPlayer: CAPPlugin {
|
public class AbsAudioPlayer: CAPPlugin {
|
||||||
private var initialPlayWhenReady = false
|
private var initialPlayWhenReady = false
|
||||||
private var initialPlaybackRate:Float = 1
|
private var isUIReady = false
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
@@ -21,10 +22,43 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||||
|
|
||||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func onReady(_ call: CAPPluginCall) {
|
||||||
|
Task { await self.restorePlaybackSession() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePlaybackSession() async {
|
||||||
|
// We don't need to restore if we have an active session
|
||||||
|
guard PlayerHandler.getPlaybackSession() == nil else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch the most recent active session
|
||||||
|
let activeSession = try await Realm().objects(PlaybackSession.self).where({
|
||||||
|
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
|
}).last?.freeze()
|
||||||
|
if let activeSession = activeSession {
|
||||||
|
await PlayerProgress.shared.syncFromServer()
|
||||||
|
try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to restore playback session")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func startPlaybackSession(_ session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) throws {
|
||||||
|
guard let libraryItemId = session.libraryItemId else { throw PlayerError.libraryItemIdNotSpecified }
|
||||||
|
|
||||||
|
self.sendPrepareMetadataEvent(itemId: libraryItemId, playWhenReady: playWhenReady)
|
||||||
|
self.sendPlaybackSession(session: try session.asDictionary())
|
||||||
|
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
self.sendMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
||||||
let libraryItemId = call.getString("libraryItemId")
|
let libraryItemId = call.getString("libraryItemId")
|
||||||
@@ -36,32 +70,42 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
NSLog("provide library item id")
|
NSLog("provide library item id")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
if libraryItemId!.starts(with: "local") {
|
|
||||||
NSLog("local items are not implemnted")
|
|
||||||
return call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
initialPlayWhenReady = playWhenReady
|
|
||||||
initialPlaybackRate = playbackRate
|
|
||||||
|
|
||||||
PlayerHandler.stopPlayback()
|
PlayerHandler.stopPlayback()
|
||||||
|
|
||||||
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
|
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
|
||||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
if (isLocalItem) {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||||
|
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||||
|
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
||||||
|
NSLog("Failed to get local playback session")
|
||||||
|
return call.resolve([:])
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
self.sendPlaybackSession(session: try session.asDictionary())
|
try playbackSession.save()
|
||||||
call.resolve(try session.asDictionary())
|
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
call.resolve(try playbackSession.asDictionary())
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to convert session to json")
|
NSLog("Failed to start session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
|
} else { // Playing from the server
|
||||||
|
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
||||||
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
do {
|
||||||
self.sendMetadata()
|
try session.save()
|
||||||
|
try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
call.resolve(try session.asDictionary())
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("Failed to start session")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve([:])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||||
NSLog("Close playback")
|
NSLog("Close playback")
|
||||||
|
|
||||||
@@ -76,7 +120,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.setPlaybackSpeed(speed: call.getFloat("value", 1.0))
|
let playbackRate = call.getFloat("value", 1.0)
|
||||||
|
let settings = PlayerSettings.main()
|
||||||
|
try? settings.update {
|
||||||
|
settings.playbackRate = playbackRate
|
||||||
|
}
|
||||||
|
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +158,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
|
|
||||||
@objc func sendMetadata() {
|
@objc func sendMetadata() {
|
||||||
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
||||||
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
|
if let metadata = PlayerHandler.getMetdata() {
|
||||||
|
self.notifyListeners("onMetadata", data: metadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@objc func sendPlaybackClosedEvent() {
|
@objc func sendPlaybackClosedEvent() {
|
||||||
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
||||||
@@ -117,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()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,31 +219,39 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
"value": PlayerHandler.getCurrentTime()
|
"value": PlayerHandler.getCurrentTime()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func sendSleepTimerSet() {
|
@objc func sendSleepTimerSet() {
|
||||||
self.notifyListeners("onSleepTimerSet", data: [
|
self.notifyListeners("onSleepTimerSet", data: [
|
||||||
"value": PlayerHandler.remainingSleepTime
|
"value": PlayerHandler.getSleepTimeRemaining()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func onLocalMediaProgressUpdate() {
|
||||||
|
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||||
|
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
||||||
|
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
||||||
|
NSLog("Sending local progress back to the UI")
|
||||||
|
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func onPlaybackFailed() {
|
@objc func onPlaybackFailed() {
|
||||||
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
||||||
let playbackSession = PlayerHandler.getPlaybackSession()
|
let session = PlayerHandler.getPlaybackSession()
|
||||||
let libraryItemId = playbackSession?.libraryItemId ?? ""
|
let libraryItemId = session?.libraryItemId ?? ""
|
||||||
let episodeId = playbackSession?.episodeId ?? nil
|
let episodeId = session?.episodeId ?? nil
|
||||||
NSLog("Forcing Transcode")
|
NSLog("Forcing Transcode")
|
||||||
|
|
||||||
// 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
|
||||||
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
|
|
||||||
|
|
||||||
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: [
|
||||||
@@ -207,7 +267,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
"playWhenReady": playWhenReady,
|
"playWhenReady": playWhenReady,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||||
self.notifyListeners("onPlaybackSession", data: session)
|
self.notifyListeners("onPlaybackSession", data: session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PlayerError: String, Error {
|
||||||
|
case libraryItemIdNotSpecified = "No libraryItemId provided on session"
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
|
|||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
|
||||||
@@ -35,13 +36,14 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
let token = call.getString("token", "")
|
let token = call.getString("token", "")
|
||||||
|
|
||||||
let name = "\(address) (\(username))"
|
let name = "\(address) (\(username))"
|
||||||
let config = ServerConnectionConfig()
|
|
||||||
|
|
||||||
if id == nil {
|
if id == nil {
|
||||||
id = "\(address)@\(username)".toBase64()
|
id = "\(address)@\(username)".toBase64()
|
||||||
}
|
}
|
||||||
|
|
||||||
config.id = id!
|
let config = ServerConnectionConfig()
|
||||||
|
config.id = id ?? ""
|
||||||
|
config.index = 0
|
||||||
config.name = name
|
config.name = name
|
||||||
config.address = address
|
config.address = address
|
||||||
config.userId = userId
|
config.userId = userId
|
||||||
@@ -49,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", "")
|
||||||
@@ -75,20 +78,157 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
do {
|
||||||
|
let items = Database.shared.getLocalLibraryItems()
|
||||||
|
call.resolve([ "value": try items.asDictionaryArray()])
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
call.resolve()
|
do {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
||||||
|
switch item {
|
||||||
|
case .some(let foundItem):
|
||||||
|
call.resolve(try foundItem.asDictionary())
|
||||||
|
default:
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
||||||
call.resolve()
|
do {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
|
||||||
|
switch item {
|
||||||
|
case .some(let foundItem):
|
||||||
|
call.resolve(try foundItem.asDictionary())
|
||||||
|
default:
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
call.resolve([ "value": [] ])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
do {
|
||||||
|
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||||
|
} catch {
|
||||||
|
NSLog("Error while loading local media progress")
|
||||||
|
debugPrint(error)
|
||||||
|
call.resolve(["value": []])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func removeLocalMediaProgress(_ call: CAPPluginCall) {
|
||||||
|
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||||
|
guard let localMediaProgressId = localMediaProgressId else {
|
||||||
|
call.reject("localMediaProgressId not specificed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
|
||||||
|
guard Store.serverConfig != nil else {
|
||||||
|
call.reject("syncLocalMediaProgressWithServer not connected to server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ApiClient.syncMediaProgress { results in
|
||||||
|
do {
|
||||||
|
call.resolve(try results.asDictionary())
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to report synced media progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) {
|
||||||
|
let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self)
|
||||||
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
let localEpisodeId = call.getString("localEpisodeId")
|
||||||
|
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||||
|
|
||||||
|
do {
|
||||||
|
guard let serverMediaProgress = serverMediaProgress else {
|
||||||
|
return call.reject("serverMediaProgress not specified")
|
||||||
|
}
|
||||||
|
guard localLibraryItemId != nil || localMediaProgressId != nil else {
|
||||||
|
return call.reject("localLibraryItemId or localMediaProgressId must be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||||
|
guard let localMediaProgress = localMediaProgress else {
|
||||||
|
call.reject("Local media progress not found or created")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
|
||||||
|
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
||||||
|
|
||||||
|
call.resolve(try localMediaProgress.asDictionary())
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to sync media progress")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
|
||||||
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
let localEpisodeId = call.getString("localEpisodeId")
|
||||||
|
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||||
|
let isFinished = call.getBool("isFinished", false)
|
||||||
|
|
||||||
|
NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||||
|
guard let localMediaProgress = localMediaProgress else {
|
||||||
|
call.resolve(["error": "Library Item not found"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update finished status
|
||||||
|
try localMediaProgress.updateIsFinished(isFinished)
|
||||||
|
|
||||||
|
// Build API response
|
||||||
|
let progressDictionary = try? localMediaProgress.asDictionary()
|
||||||
|
var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
|
||||||
|
|
||||||
|
// Send update to the server if logged in
|
||||||
|
let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil
|
||||||
|
let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId
|
||||||
|
if hasLinkedServer && loggedIntoServer {
|
||||||
|
response["server"] = true
|
||||||
|
let payload = ["isFinished": isFinished]
|
||||||
|
ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) {
|
||||||
|
call.resolve(response)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
call.resolve(response)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
call.resolve(["error": "Failed to mark as complete"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
||||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||||
let enableAltView = call.getBool("enableAltView") ?? false
|
let enableAltView = call.getBool("enableAltView") ?? false
|
||||||
|
|||||||
@@ -7,65 +7,395 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
@objc(AbsDownloader)
|
@objc(AbsDownloader)
|
||||||
public class AbsDownloader: CAPPlugin {
|
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||||
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
|
||||||
let libraryItemId = call.getString("libraryItemId")
|
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
let episodeId = call.getString("episodeId")
|
|
||||||
|
private lazy var session: URLSession = {
|
||||||
NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")")
|
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
||||||
|
let queue = OperationQueue()
|
||||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in
|
queue.maxConcurrentOperationCount = 5
|
||||||
if (libraryItem == nil) {
|
return URLSession(configuration: config, delegate: self, delegateQueue: queue)
|
||||||
NSLog("Library item not found")
|
}()
|
||||||
call.resolve()
|
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||||
} else {
|
private var downloadItemProgress = [String: DownloadItem]()
|
||||||
NSLog("Got library item \(libraryItem!)")
|
private var monitoringProgressTimer: Timer?
|
||||||
|
|
||||||
// TODO: break out in seperate functions
|
|
||||||
libraryItem!.media.tracks?.forEach { track in
|
// MARK: - Progress handling
|
||||||
NSLog("TRACK \(track.contentUrl!)")
|
|
||||||
// filename needs to be encoded otherwise would just use contentUrl
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
let filename = track.metadata?.filename ?? ""
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
let realm = try Realm()
|
||||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
try realm.write {
|
||||||
let url = URL(string: urlstr)!
|
downloadItemPart.progress = 100
|
||||||
|
downloadItemPart.completed = true
|
||||||
|
}
|
||||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
||||||
let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItemId!)")
|
|
||||||
NSLog("ITEM DIR \(itemDirectory)")
|
|
||||||
|
|
||||||
// Create library item directory
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false)
|
|
||||||
} catch {
|
|
||||||
NSLog("Failed to CREATE LI DIRECTORY \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output filename
|
|
||||||
let trackFilename = itemDirectory.appendingPathComponent("\(filename)")
|
|
||||||
|
|
||||||
let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in
|
|
||||||
|
|
||||||
guard let fileURL = urlOrNil else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
NSLog("Download TMP file URL \(fileURL)")
|
|
||||||
let imageData = try Data(contentsOf:fileURL)
|
|
||||||
try imageData.write(to: trackFilename)
|
|
||||||
NSLog("Download written to \(trackFilename)")
|
|
||||||
} catch {
|
|
||||||
NSLog("FILE ERROR: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadTask.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
do {
|
||||||
call.resolve()
|
// Move the downloaded file into place
|
||||||
|
guard let destinationUrl = downloadItemPart.destinationURL else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: destinationUrl)
|
||||||
|
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||||
|
try realm.write {
|
||||||
|
downloadItemPart.moved = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try realm.write {
|
||||||
|
downloadItemPart.failed = true
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||||
|
if let error = error {
|
||||||
|
try Realm().write {
|
||||||
|
downloadItemPart.completed = true
|
||||||
|
downloadItemPart.failed = true
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
|
// Calculate the download percentage
|
||||||
|
let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100
|
||||||
|
// Only update the progress if we received accurate progress data
|
||||||
|
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||||
|
try Realm().write {
|
||||||
|
downloadItemPart.progress = percentDownloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when downloads are complete on the background thread
|
||||||
|
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
|
||||||
|
let backgroundCompletionHandler =
|
||||||
|
appDelegate.backgroundCompletionHandler else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backgroundCompletionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||||
|
do {
|
||||||
|
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
||||||
|
NSLog("Received download update for \(downloadItemPartId)")
|
||||||
|
|
||||||
|
// Find the download item
|
||||||
|
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
||||||
|
guard var downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound }
|
||||||
|
|
||||||
|
// Find the download item part
|
||||||
|
let part = downloadItem.downloadItemParts.first(where: { $0.id == downloadItemPartId })
|
||||||
|
guard let part = part else { throw LibraryItemDownloadError.downloadItemPartNotFound }
|
||||||
|
|
||||||
|
// Call the progress handler
|
||||||
|
do {
|
||||||
|
try progressHandler(downloadItem, part)
|
||||||
|
} catch {
|
||||||
|
NSLog("Error while processing progress")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
downloadItem = downloadItem.freeze()
|
||||||
|
self.progressStatusQueue.async(flags: .barrier) {
|
||||||
|
self.downloadItemProgress.updateValue(downloadItem, forKey: downloadItem.id!)
|
||||||
|
}
|
||||||
|
self.notifyDownloadProgress()
|
||||||
|
} catch {
|
||||||
|
NSLog("DownloadItemError")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
|
||||||
|
private func notifyDownloadProgress() {
|
||||||
|
if self.monitoringProgressTimer?.isValid ?? false {
|
||||||
|
NSLog("Already monitoring progress, no need to start timer again")
|
||||||
|
} else {
|
||||||
|
DispatchQueue.runOnMainQueue {
|
||||||
|
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in
|
||||||
|
NSLog("Starting monitoring download progress...")
|
||||||
|
|
||||||
|
// Fetch active downloads in a thread-safe way
|
||||||
|
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
||||||
|
self.progressStatusQueue.sync {
|
||||||
|
let activeDownloads = self.downloadItemProgress
|
||||||
|
if activeDownloads.isEmpty {
|
||||||
|
NSLog("Finishing monitoring download progress...")
|
||||||
|
t.invalidate()
|
||||||
|
}
|
||||||
|
return activeDownloads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a completed download item in a thread-safe way
|
||||||
|
func handleDoneDownloadItem(_ item: DownloadItem) {
|
||||||
|
self.progressStatusQueue.async(flags: .barrier) {
|
||||||
|
self.downloadItemProgress.removeValue(forKey: item.id!)
|
||||||
|
}
|
||||||
|
self.handleDownloadTaskCompleteFromDownloadItem(item)
|
||||||
|
if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) {
|
||||||
|
try? item.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit status for active downloads
|
||||||
|
if let activeDownloads = fetchActiveDownloads() {
|
||||||
|
for item in activeDownloads.values {
|
||||||
|
try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary())
|
||||||
|
if item.isDoneDownloading() { handleDoneDownloadItem(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) {
|
||||||
|
var statusNotification = [String: Any]()
|
||||||
|
statusNotification["libraryItemId"] = downloadItem.id
|
||||||
|
|
||||||
|
if ( downloadItem.didDownloadSuccessfully() ) {
|
||||||
|
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in
|
||||||
|
guard let libraryItem = libraryItem else { NSLog("LibraryItem not found"); return }
|
||||||
|
let localDirectory = libraryItem.id
|
||||||
|
var coverFile: String?
|
||||||
|
|
||||||
|
// Assemble the local library item
|
||||||
|
let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in
|
||||||
|
var mimeType = part.mimeType()
|
||||||
|
if part.filename == "cover.jpg" {
|
||||||
|
coverFile = part.destinationUri
|
||||||
|
mimeType = "image/jpg"
|
||||||
|
}
|
||||||
|
return LocalFile(libraryItem.id, part.filename!, mimeType!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize))
|
||||||
|
}
|
||||||
|
var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: libraryItem.id)
|
||||||
|
if (localLibraryItem != nil && localLibraryItem!.isPodcast) {
|
||||||
|
try? Realm().write {
|
||||||
|
try? localLibraryItem?.addFiles(files, item: libraryItem)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile)
|
||||||
|
try? Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary()
|
||||||
|
|
||||||
|
if let progress = libraryItem.userMediaProgress {
|
||||||
|
let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId })
|
||||||
|
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress)
|
||||||
|
try? localMediaProgress.save()
|
||||||
|
statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Capacitor functions
|
||||||
|
|
||||||
|
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
||||||
|
let libraryItemId = call.getString("libraryItemId")
|
||||||
|
var episodeId = call.getString("episodeId")
|
||||||
|
if ( episodeId == "null" ) { episodeId = nil }
|
||||||
|
|
||||||
|
NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
|
||||||
|
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
|
||||||
|
|
||||||
|
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in
|
||||||
|
if let libraryItem = libraryItem {
|
||||||
|
NSLog("Got library item from server \(libraryItem.id)")
|
||||||
|
do {
|
||||||
|
if let episodeId = episodeId {
|
||||||
|
// Download a podcast episode
|
||||||
|
guard libraryItem.mediaType == "podcast" else { throw LibraryItemDownloadError.libraryItemNotPodcast }
|
||||||
|
let episode = libraryItem.media?.episodes.enumerated().first(where: { $1.id == episodeId })?.element
|
||||||
|
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
|
||||||
|
try self.startLibraryItemDownload(libraryItem, episode: episode)
|
||||||
|
} else {
|
||||||
|
// Download a book
|
||||||
|
try self.startLibraryItemDownload(libraryItem)
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
call.resolve(["error": "Failed to download"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
call.resolve(["error": "Server request failed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemDownload(_ item: LibraryItem) throws {
|
||||||
|
try startLibraryItemDownload(item, episode: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemDownload(_ item: LibraryItem, episode: PodcastEpisode?) throws {
|
||||||
|
let tracks = List<AudioTrack>()
|
||||||
|
var episodeId: String?
|
||||||
|
|
||||||
|
// Handle the different media type downloads
|
||||||
|
switch item.mediaType {
|
||||||
|
case "book":
|
||||||
|
guard item.media?.tracks.count ?? 0 > 0 else { throw LibraryItemDownloadError.noTracks }
|
||||||
|
item.media?.tracks.forEach { t in tracks.append(AudioTrack.detachCopy(of: t)!) }
|
||||||
|
case "podcast":
|
||||||
|
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
|
||||||
|
guard let podcastTrack = episode.audioTrack else { throw LibraryItemDownloadError.noTracks }
|
||||||
|
episodeId = episode.id
|
||||||
|
tracks.append(AudioTrack.detachCopy(of: podcastTrack)!)
|
||||||
|
default:
|
||||||
|
throw LibraryItemDownloadError.unknownMediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue up everything for downloading
|
||||||
|
let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!)
|
||||||
|
var tasks = [DownloadItemPartTask]()
|
||||||
|
for (i, track) in tracks.enumerated() {
|
||||||
|
let task = try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode)
|
||||||
|
downloadItem.downloadItemParts.append(task.part)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also download the cover
|
||||||
|
if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) {
|
||||||
|
if let task = try? startLibraryItemCoverDownload(item: item) {
|
||||||
|
downloadItem.downloadItemParts.append(task.part)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist in the database before status start coming in
|
||||||
|
try Database.shared.saveDownloadItem(downloadItem)
|
||||||
|
|
||||||
|
// Start all the downloads
|
||||||
|
for task in tasks {
|
||||||
|
task.task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||||
|
NSLog("TRACK \(track.contentUrl!)")
|
||||||
|
|
||||||
|
// If we don't name metadata, then we can't proceed
|
||||||
|
guard let filename = track.metadata?.filename else {
|
||||||
|
throw LibraryItemDownloadError.noMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverUrl = urlForTrack(item: item, track: track)
|
||||||
|
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||||
|
let localUrl = "\(itemDirectory)/\(filename)"
|
||||||
|
|
||||||
|
let task = session.downloadTask(with: serverUrl)
|
||||||
|
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode)
|
||||||
|
|
||||||
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
|
task.taskDescription = part.id
|
||||||
|
|
||||||
|
return DownloadItemPartTask(part: part, task: task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemCoverDownload(item: LibraryItem) throws -> DownloadItemPartTask {
|
||||||
|
let filename = "cover.jpg"
|
||||||
|
let serverPath = "/api/items/\(item.id)/cover"
|
||||||
|
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||||
|
let localUrl = "\(itemDirectory)/\(filename)"
|
||||||
|
|
||||||
|
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil)
|
||||||
|
let task = session.downloadTask(with: part.downloadURL!)
|
||||||
|
|
||||||
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
|
task.taskDescription = part.id
|
||||||
|
|
||||||
|
return DownloadItemPartTask(part: part, task: task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
||||||
|
// filename needs to be encoded otherwise would just use contentUrl
|
||||||
|
let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||||
|
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||||
|
return URL(string: urlstr)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
||||||
|
let itemDirectory = item.id
|
||||||
|
NSLog("ITEM DIR \(itemDirectory)")
|
||||||
|
|
||||||
|
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
||||||
|
NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)")
|
||||||
|
throw LibraryItemDownloadError.failedDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
static func itemDownloadFolder(path: String) -> URL? {
|
||||||
|
do {
|
||||||
|
var itemFolder = AbsDownloader.downloadsDirectory.appendingPathComponent(path)
|
||||||
|
|
||||||
|
if !FileManager.default.fileExists(atPath: itemFolder.path) {
|
||||||
|
try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we don't backup download files to iCloud
|
||||||
|
var resourceValues = URLResourceValues()
|
||||||
|
resourceValues.isExcludedFromBackup = true
|
||||||
|
try itemFolder.setResourceValues(resourceValues)
|
||||||
|
|
||||||
|
return itemFolder
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to CREATE LI DIRECTORY \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Class structs
|
||||||
|
|
||||||
|
typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: DownloadItemPart) throws -> Void
|
||||||
|
|
||||||
|
struct DownloadItemPartTask {
|
||||||
|
let part: DownloadItemPart
|
||||||
|
let task: URLSessionDownloadTask
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LibraryItemDownloadError: String, Error {
|
||||||
|
case noTracks = "No tracks on library item"
|
||||||
|
case noMetadata = "No metadata for track, unable to download"
|
||||||
|
case libraryItemNotPodcast = "Library item is not a podcast but episode was requested"
|
||||||
|
case podcastEpisodeNotFound = "Invalid podcast episode not found"
|
||||||
|
case podcastOnlySupported = "Only podcasts are supported for this function"
|
||||||
|
case unknownMediaType = "Unknown media type"
|
||||||
|
case failedDirectory = "Failed to create directory"
|
||||||
|
case failedDownload = "Failed to download item"
|
||||||
|
case noTaskDescription = "No task description"
|
||||||
|
case downloadItemNotFound = "DownloadItem not found"
|
||||||
|
case downloadItemPartNotFound = "DownloadItemPart not found"
|
||||||
|
case downloadItemPartDestinationUrlNotDefined = "DownloadItemPart destination URL not defined"
|
||||||
|
case libraryItemNotFound = "LibraryItem not found for id"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,79 +13,110 @@ public class AbsFileSystem: CAPPlugin {
|
|||||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||||
let mediaType = call.getString("mediaType")
|
let mediaType = call.getString("mediaType")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
|
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||||
let folderUrl = call.getString("folderUrl")
|
let folderUrl = call.getString("folderUrl")
|
||||||
|
|
||||||
// TODO: Is this even necessary on iOS?
|
|
||||||
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve([
|
call.unavailable("Not available on iOS")
|
||||||
"value": true
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func scanFolder(_ call: CAPPluginCall) {
|
@objc func scanFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("removeFolder \(folderId ?? "UNSET")")
|
NSLog("removeFolder \(folderId ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func deleteItem(_ call: CAPPluginCall) {
|
@objc func deleteItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("id")
|
||||||
let contentUrl = call.getString("contentUrl")
|
let contentUrl = call.getString("contentUrl")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
var success = false
|
||||||
|
do {
|
||||||
|
if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||||
|
try FileManager.default.removeItem(at: item.contentDirectory!)
|
||||||
|
try item.delete()
|
||||||
|
success = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to delete \(error)")
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
|
||||||
|
call.resolve(["success": success])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
|
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("id")
|
||||||
let trackLocalFileId = call.getString("trackLocalFileId")
|
let trackLocalFileId = call.getString("trackLocalFileId")
|
||||||
let contentUrl = call.getString("contentUrl")
|
|
||||||
|
|
||||||
// TODO: Implement
|
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
||||||
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
|
||||||
|
|
||||||
call.resolve()
|
var success = false
|
||||||
|
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||||
|
do {
|
||||||
|
try item.update {
|
||||||
|
do {
|
||||||
|
if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) {
|
||||||
|
try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath)
|
||||||
|
item.realm?.delete(item.localFiles[fileIndex])
|
||||||
|
if item.isPodcast, let media = item.media {
|
||||||
|
if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) {
|
||||||
|
media.episodes.remove(at: episodeIndex)
|
||||||
|
}
|
||||||
|
item.media = media
|
||||||
|
}
|
||||||
|
call.resolve(try item.asDictionary())
|
||||||
|
success = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to delete \(error)")
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to delete \(error)")
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
call.resolve(["success": success])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
//
|
|
||||||
// DataClasses.swift
|
|
||||||
// App
|
|
||||||
//
|
|
||||||
// Created by Benonymity on 4/20/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreMedia
|
|
||||||
|
|
||||||
struct LibraryItem: Codable {
|
|
||||||
var id: String
|
|
||||||
var ino:String
|
|
||||||
var libraryId: String
|
|
||||||
var folderId: String
|
|
||||||
var path: String
|
|
||||||
var relPath: String
|
|
||||||
var isFile: Bool
|
|
||||||
var mtimeMs: Int64
|
|
||||||
var ctimeMs: Int64
|
|
||||||
var birthtimeMs: Int64
|
|
||||||
var addedAt: Int64
|
|
||||||
var updatedAt: Int64
|
|
||||||
var lastScan: Int64?
|
|
||||||
var scanVersion: String?
|
|
||||||
var isMissing: Bool
|
|
||||||
var isInvalid: Bool
|
|
||||||
var mediaType: String
|
|
||||||
var media: MediaType
|
|
||||||
var libraryFiles: [LibraryFile]
|
|
||||||
var userMediaProgress:MediaProgress?
|
|
||||||
}
|
|
||||||
struct MediaType: Codable {
|
|
||||||
var libraryItemId: String?
|
|
||||||
var metadata: Metadata
|
|
||||||
var coverPath: String?
|
|
||||||
var tags: [String]?
|
|
||||||
var audioFiles: [AudioTrack]?
|
|
||||||
var chapters: [Chapter]?
|
|
||||||
var tracks: [AudioTrack]?
|
|
||||||
var size: Int64?
|
|
||||||
var duration: Double?
|
|
||||||
var episodes: [PodcastEpisode]?
|
|
||||||
var autoDownloadEpisodes: Bool?
|
|
||||||
}
|
|
||||||
struct Metadata: Codable {
|
|
||||||
var title: String
|
|
||||||
var subtitle: String?
|
|
||||||
var authors: [Author]?
|
|
||||||
var narrators: [String]?
|
|
||||||
var genres: [String]
|
|
||||||
var publishedYear: String?
|
|
||||||
var publishedDate: String?
|
|
||||||
var publisher: String?
|
|
||||||
var description: String?
|
|
||||||
var isbn: String?
|
|
||||||
var asin: String?
|
|
||||||
var language: String?
|
|
||||||
var explicit: Bool
|
|
||||||
var authorName: String?
|
|
||||||
var authorNameLF: String?
|
|
||||||
var narratorName: String?
|
|
||||||
var seriesName: String?
|
|
||||||
var feedUrl: String?
|
|
||||||
}
|
|
||||||
struct PodcastEpisode: Codable {
|
|
||||||
var id: String
|
|
||||||
var index: Int
|
|
||||||
var episode: String?
|
|
||||||
var episodeType: String?
|
|
||||||
var title: String
|
|
||||||
var subtitle: String?
|
|
||||||
var description: String?
|
|
||||||
var audioFile: AudioFile?
|
|
||||||
var audioTrack: AudioTrack?
|
|
||||||
var duration: Double
|
|
||||||
var size: Int64
|
|
||||||
// var serverEpisodeId: String?
|
|
||||||
}
|
|
||||||
struct AudioFile: Codable {
|
|
||||||
var index: Int
|
|
||||||
var ino: String
|
|
||||||
var metadata: FileMetadata
|
|
||||||
}
|
|
||||||
struct Author: Codable {
|
|
||||||
var id: String
|
|
||||||
var name: String
|
|
||||||
var coverPath: String?
|
|
||||||
}
|
|
||||||
struct Chapter: Codable {
|
|
||||||
var id: Int
|
|
||||||
var start: Double
|
|
||||||
var end: Double
|
|
||||||
var title: String?
|
|
||||||
}
|
|
||||||
struct AudioTrack: Codable {
|
|
||||||
var index: Int?
|
|
||||||
var startOffset: Double?
|
|
||||||
var duration: Double
|
|
||||||
var title: String?
|
|
||||||
var contentUrl: String?
|
|
||||||
var mimeType: String
|
|
||||||
var metadata: FileMetadata?
|
|
||||||
// var isLocal: Bool
|
|
||||||
// var localFileId: String?
|
|
||||||
// var audioProbeResult: AudioProbeResult? Needed for local playback
|
|
||||||
var serverIndex: Int?
|
|
||||||
}
|
|
||||||
struct FileMetadata: Codable {
|
|
||||||
var filename: String
|
|
||||||
var ext: String
|
|
||||||
var path: String
|
|
||||||
var relPath: String
|
|
||||||
}
|
|
||||||
struct Library: Codable {
|
|
||||||
var id: String
|
|
||||||
var name: String
|
|
||||||
var folders: [Folder]
|
|
||||||
var icon: String
|
|
||||||
var mediaType: String
|
|
||||||
}
|
|
||||||
struct Folder: Codable {
|
|
||||||
var id: String
|
|
||||||
var fullPath: String
|
|
||||||
}
|
|
||||||
struct LibraryFile: Codable {
|
|
||||||
var ino: String
|
|
||||||
var metadata: FileMetadata
|
|
||||||
}
|
|
||||||
struct MediaProgress:Codable {
|
|
||||||
var id:String
|
|
||||||
var libraryItemId:String
|
|
||||||
var episodeId:String?
|
|
||||||
var duration:Double
|
|
||||||
var progress:Double
|
|
||||||
var currentTime:Double
|
|
||||||
var isFinished:Bool
|
|
||||||
var lastUpdate:Int64
|
|
||||||
var startedAt:Int64
|
|
||||||
var finishedAt:Int64?
|
|
||||||
}
|
|
||||||
@@ -9,28 +9,21 @@ import Foundation
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class DeviceSettings: Object {
|
class DeviceSettings: Object {
|
||||||
@Persisted var disableAutoRewind: Bool
|
@Persisted var disableAutoRewind: Bool = false
|
||||||
@Persisted var enableAltView: Bool
|
@Persisted var enableAltView: Bool = false
|
||||||
@Persisted var jumpBackwardsTime: Int
|
@Persisted var jumpBackwardsTime: Int = 10
|
||||||
@Persisted var jumpForwardTime: Int
|
@Persisted var jumpForwardTime: Int = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||||
let settings = DeviceSettings()
|
return DeviceSettings()
|
||||||
settings.disableAutoRewind = false
|
|
||||||
settings.enableAltView = false
|
|
||||||
settings.jumpForwardTime = 10
|
|
||||||
settings.jumpBackwardsTime = 10
|
|
||||||
return settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||||
return Database.realmQueue.sync {
|
return [
|
||||||
return [
|
"disableAutoRewind": settings.disableAutoRewind,
|
||||||
"disableAutoRewind": settings.disableAutoRewind,
|
"enableAltView": settings.enableAltView,
|
||||||
"enableAltView": settings.enableAltView,
|
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
"jumpForwardTime": settings.jumpForwardTime
|
||||||
"jumpForwardTime": settings.jumpForwardTime
|
]
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// PlaybackMetadata.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class PlaybackMetadata: Codable {
|
||||||
|
var duration: Double = 0
|
||||||
|
var currentTime: Double = 0
|
||||||
|
var playerState: PlayerState = PlayerState.IDLE
|
||||||
|
}
|
||||||
@@ -6,27 +6,156 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
struct PlaybackSession: Decodable, Encodable {
|
class PlaybackSession: Object, Codable, Deletable {
|
||||||
var id: String
|
@Persisted(primaryKey: true) var id: String = ""
|
||||||
var userId: String?
|
@Persisted var userId: String?
|
||||||
var libraryItemId: String?
|
@Persisted var libraryItemId: String?
|
||||||
var episodeId: String?
|
@Persisted var episodeId: String?
|
||||||
var mediaType: String
|
@Persisted var mediaType: String = ""
|
||||||
// var mediaMetadata: MediaTypeMetadata - It is not implemented in android?
|
@Persisted var mediaMetadata: Metadata?
|
||||||
var chapters: [Chapter]
|
@Persisted var chapters = List<Chapter>()
|
||||||
var displayTitle: String?
|
@Persisted var displayTitle: String?
|
||||||
var displayAuthor: String?
|
@Persisted var displayAuthor: String?
|
||||||
var coverPath: String?
|
@Persisted var coverPath: String?
|
||||||
var duration: Double
|
@Persisted var duration: Double = 0
|
||||||
var playMethod: Int
|
@Persisted var playMethod: Int = PlayMethod.directplay.rawValue
|
||||||
var startedAt: Double?
|
@Persisted var startedAt: Double?
|
||||||
var updatedAt: Double?
|
@Persisted var updatedAt: Double?
|
||||||
var timeListening: Double
|
@Persisted var timeListening: Double = 0
|
||||||
var audioTracks: [AudioTrack]
|
@Persisted var audioTracks = List<AudioTrack>()
|
||||||
var currentTime: Double
|
@Persisted var currentTime: Double = 0
|
||||||
var libraryItem: LibraryItem
|
@Persisted var libraryItem: LibraryItem?
|
||||||
// var localLibraryItem: LocalLibraryItem?
|
@Persisted var localLibraryItem: LocalLibraryItem?
|
||||||
var serverConnectionConfigId: String?
|
@Persisted var serverConnectionConfigId: String?
|
||||||
var serverAddress: String?
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var isActiveSession = true
|
||||||
|
@Persisted var serverUpdatedAt: Double = 0
|
||||||
|
|
||||||
|
var isLocal: Bool { self.localLibraryItem != nil }
|
||||||
|
var mediaPlayer: String { "AVPlayer" }
|
||||||
|
var deviceInfo: [String: String?]? {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
|
||||||
|
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||||
|
ptr in String.init(validatingUTF8: ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"manufacturer": "Apple",
|
||||||
|
"model": modelCode,
|
||||||
|
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var localMediaProgressId: String? {
|
||||||
|
if let localLibraryItem = localLibraryItem, let episodeId = episodeId {
|
||||||
|
return "\(localLibraryItem.id)-\(episodeId)"
|
||||||
|
} else if let localLibraryItem = localLibraryItem {
|
||||||
|
return localLibraryItem.id
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalDuration: Double {
|
||||||
|
var total = 0.0
|
||||||
|
self.audioTracks.forEach { total += $0.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double { self.currentTime / self.totalDuration }
|
||||||
|
|
||||||
|
internal init(id: String, userId: String? = nil, libraryItemId: String? = nil, episodeId: String? = nil, mediaType: String, mediaMetadata: Metadata?, chapters: List<Chapter> = List<Chapter>(), displayTitle: String? = nil, displayAuthor: String? = nil, coverPath: String? = nil, duration: Double, playMethod: Int, startedAt: Double? = nil, updatedAt: Double? = nil, timeListening: Double, audioTracks: List<AudioTrack> = List<AudioTrack>(), currentTime: Double, libraryItem: LibraryItem? = nil, localLibraryItem: LocalLibraryItem? = nil, serverConnectionConfigId: String? = nil, serverAddress: String? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.userId = userId
|
||||||
|
self.libraryItemId = libraryItemId
|
||||||
|
self.episodeId = episodeId
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.mediaMetadata = mediaMetadata
|
||||||
|
self.chapters = chapters
|
||||||
|
self.displayTitle = displayTitle
|
||||||
|
self.displayAuthor = displayAuthor
|
||||||
|
self.coverPath = coverPath
|
||||||
|
self.duration = duration
|
||||||
|
self.playMethod = playMethod
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.timeListening = timeListening
|
||||||
|
self.audioTracks = audioTracks
|
||||||
|
self.currentTime = currentTime
|
||||||
|
self.libraryItem = libraryItem
|
||||||
|
self.localLibraryItem = localLibraryItem
|
||||||
|
self.serverConnectionConfigId = serverConnectionConfigId
|
||||||
|
self.serverAddress = serverAddress
|
||||||
|
self.isActiveSession = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, userId, libraryItemId, episodeId, mediaType, mediaMetadata, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, mediaPlayer, deviceInfo, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
userId = try values.decodeIfPresent(String.self, forKey: .userId)
|
||||||
|
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
|
||||||
|
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||||
|
mediaMetadata = try values.decodeIfPresent(Metadata.self, forKey: .mediaMetadata)
|
||||||
|
if let chapterList = try values.decodeIfPresent([Chapter].self, forKey: .chapters) {
|
||||||
|
chapters.append(objectsIn: chapterList)
|
||||||
|
}
|
||||||
|
displayTitle = try values.decodeIfPresent(String.self, forKey: .displayTitle)
|
||||||
|
displayAuthor = try values.decodeIfPresent(String.self, forKey: .displayAuthor)
|
||||||
|
coverPath = try values.decodeIfPresent(String.self, forKey: .coverPath)
|
||||||
|
duration = try values.decode(Double.self, forKey: .duration)
|
||||||
|
playMethod = try values.decode(Int.self, forKey: .playMethod)
|
||||||
|
startedAt = try values.decodeIfPresent(Double.self, forKey: .startedAt)
|
||||||
|
updatedAt = try values.decodeIfPresent(Double.self, forKey: .updatedAt)
|
||||||
|
timeListening = try values.decode(Double.self, forKey: .timeListening)
|
||||||
|
if let trackList = try values.decodeIfPresent([AudioTrack].self, forKey: .audioTracks) {
|
||||||
|
audioTracks.append(objectsIn: trackList)
|
||||||
|
}
|
||||||
|
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||||
|
libraryItem = try values.decodeIfPresent(LibraryItem.self, forKey: .libraryItem)
|
||||||
|
localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem)
|
||||||
|
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||||
|
isActiveSession = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(userId, forKey: .userId)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(mediaMetadata, forKey: .mediaMetadata)
|
||||||
|
try container.encode(Array(chapters), forKey: .chapters)
|
||||||
|
try container.encode(displayTitle, forKey: .displayTitle)
|
||||||
|
try container.encode(displayAuthor, forKey: .displayAuthor)
|
||||||
|
try container.encode(coverPath, forKey: .coverPath)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(playMethod, forKey: .playMethod)
|
||||||
|
try container.encode(mediaPlayer, forKey: .mediaPlayer)
|
||||||
|
try container.encode(deviceInfo, forKey: .deviceInfo)
|
||||||
|
try container.encode(startedAt, forKey: .startedAt)
|
||||||
|
try container.encode(updatedAt, forKey: .updatedAt)
|
||||||
|
try container.encode(timeListening, forKey: .timeListening)
|
||||||
|
try container.encode(Array(audioTracks), forKey: .audioTracks)
|
||||||
|
try container.encode(currentTime, forKey: .currentTime)
|
||||||
|
try container.encode(libraryItem, forKey: .libraryItem)
|
||||||
|
try container.encode(localLibraryItem, forKey: .localLibraryItem)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(isLocal, forKey: .isLocal)
|
||||||
|
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// PlayerSettings.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/18/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class PlayerSettings: Object {
|
||||||
|
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
|
||||||
|
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
|
||||||
|
@Persisted var playbackRate: Float = 1.0
|
||||||
|
|
||||||
|
// Singleton pattern for Realm objects
|
||||||
|
static func main() -> PlayerSettings {
|
||||||
|
let realm = try! Realm()
|
||||||
|
|
||||||
|
if let settings = realm.objects(PlayerSettings.self).last {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = PlayerSettings()
|
||||||
|
try! realm.write {
|
||||||
|
realm.add(settings)
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// PlayerState.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PlayerState: Codable {
|
||||||
|
case IDLE
|
||||||
|
case BUFFERING
|
||||||
|
case READY
|
||||||
|
case ENDED
|
||||||
|
}
|
||||||
@@ -9,30 +9,28 @@ import Foundation
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class ServerConnectionConfig: Object {
|
class ServerConnectionConfig: Object {
|
||||||
@Persisted(primaryKey: true) var id: String
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
@Persisted(indexed: true) var index: Int
|
@Persisted(indexed: true) var index: Int = 1
|
||||||
@Persisted var name: String
|
@Persisted var name: String = ""
|
||||||
@Persisted var address: String
|
@Persisted var address: String = ""
|
||||||
@Persisted var userId: String
|
@Persisted var userId: String = ""
|
||||||
@Persisted var username: String
|
@Persisted var username: String = ""
|
||||||
@Persisted var token: String
|
@Persisted var token: String = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServerConnectionConfigActiveIndex: Object {
|
class ServerConnectionConfigActiveIndex: Object {
|
||||||
// This could overflow, but you really would have to try
|
// This could overflow, but you really would have to try
|
||||||
@Persisted var index: Int?
|
@Persisted(primaryKey: true) var index: Int?
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
|
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
|
||||||
return Database.realmQueue.sync {
|
return [
|
||||||
return [
|
"id": config.id,
|
||||||
"id": config.id,
|
"name": config.name,
|
||||||
"name": config.name,
|
"index": config.index,
|
||||||
"index": config.index,
|
"address": config.address,
|
||||||
"address": config.address,
|
"userId": config.userId,
|
||||||
"userId": config.userId,
|
"username": config.username,
|
||||||
"username": config.username,
|
"token": config.token,
|
||||||
"token": config.token,
|
]
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// DownloadItem.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class DownloadItem: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String?
|
||||||
|
@Persisted(indexed: true) var libraryItemId: String?
|
||||||
|
@Persisted var episodeId: String?
|
||||||
|
@Persisted var userMediaProgress: MediaProgress?
|
||||||
|
@Persisted var serverConnectionConfigId: String?
|
||||||
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var serverUserId: String?
|
||||||
|
@Persisted var mediaType: String?
|
||||||
|
@Persisted var itemTitle: String?
|
||||||
|
@Persisted var media: MediaType?
|
||||||
|
@Persisted var downloadItemParts = List<DownloadItemPart>()
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, libraryItemId, episodeId, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try? values.decode(String.self, forKey: .id)
|
||||||
|
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||||
|
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
|
||||||
|
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
|
||||||
|
mediaType = try? values.decode(String.self, forKey: .mediaType)
|
||||||
|
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||||
|
if let parts = try? values.decode([DownloadItemPart].self, forKey: .downloadItemParts) {
|
||||||
|
downloadItemParts.append(objectsIn: parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(serverUserId, forKey: .serverUserId)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(itemTitle, forKey: .itemTitle)
|
||||||
|
try container.encode(Array(downloadItemParts), forKey: .downloadItemParts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DownloadItem {
|
||||||
|
convenience init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = libraryItem.id
|
||||||
|
self.libraryItemId = libraryItem.id
|
||||||
|
self.userMediaProgress = libraryItem.userMediaProgress
|
||||||
|
self.serverConnectionConfigId = server.id
|
||||||
|
self.serverAddress = server.address
|
||||||
|
self.serverUserId = server.userId
|
||||||
|
self.mediaType = libraryItem.mediaType
|
||||||
|
self.itemTitle = libraryItem.media?.metadata?.title
|
||||||
|
self.media = libraryItem.media
|
||||||
|
|
||||||
|
if let episodeId = episodeId {
|
||||||
|
self.id! += "-\(episodeId)"
|
||||||
|
self.episodeId = episodeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDoneDownloading() -> Bool {
|
||||||
|
self.downloadItemParts.allSatisfy({ $0.completed })
|
||||||
|
}
|
||||||
|
|
||||||
|
func didDownloadSuccessfully() -> Bool {
|
||||||
|
self.downloadItemParts.allSatisfy({ $0.failed == false })
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.realm?.delete(self.downloadItemParts)
|
||||||
|
self.realm?.delete(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// DownloadItemPart.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class DownloadItemPart: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id = ""
|
||||||
|
@Persisted var filename: String?
|
||||||
|
@Persisted var itemTitle: String?
|
||||||
|
@Persisted var serverPath: String?
|
||||||
|
@Persisted var audioTrack: AudioTrack?
|
||||||
|
@Persisted var episode: PodcastEpisode?
|
||||||
|
@Persisted var completed: Bool = false
|
||||||
|
@Persisted var moved: Bool = false
|
||||||
|
@Persisted var failed: Bool = false
|
||||||
|
@Persisted var uri: String?
|
||||||
|
@Persisted var destinationUri: String?
|
||||||
|
@Persisted var progress: Double = 0
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, filename, itemTitle, completed, moved, failed, progress
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
filename = try? values.decode(String.self, forKey: .filename)
|
||||||
|
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||||
|
completed = try values.decode(Bool.self, forKey: .completed)
|
||||||
|
moved = try values.decode(Bool.self, forKey: .moved)
|
||||||
|
failed = try values.decode(Bool.self, forKey: .failed)
|
||||||
|
progress = try values.decode(Double.self, forKey: .progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(filename, forKey: .filename)
|
||||||
|
try container.encode(itemTitle, forKey: .itemTitle)
|
||||||
|
try container.encode(completed, forKey: .completed)
|
||||||
|
try container.encode(moved, forKey: .moved)
|
||||||
|
try container.encode(failed, forKey: .failed)
|
||||||
|
try container.encode(progress, forKey: .progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DownloadItemPart {
|
||||||
|
convenience init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = destination.toBase64()
|
||||||
|
self.filename = filename
|
||||||
|
self.itemTitle = itemTitle
|
||||||
|
self.serverPath = serverPath
|
||||||
|
self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
|
||||||
|
self.episode = PodcastEpisode.detachCopy(of: episode)
|
||||||
|
|
||||||
|
let config = Store.serverConfig!
|
||||||
|
var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)"
|
||||||
|
if (serverPath.hasSuffix("/cover")) {
|
||||||
|
downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||||
|
}
|
||||||
|
self.uri = downloadUrl
|
||||||
|
self.destinationUri = destination
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadURL: URL? {
|
||||||
|
if let uri = self.uri {
|
||||||
|
return URL(string: uri)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinationURL: URL? {
|
||||||
|
if let destinationUri = self.destinationUri {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: destinationUri)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeType() -> String? {
|
||||||
|
audioTrack?.mimeType ?? episode?.audioTrack?.mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// LocalFile.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalFile: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
|
@Persisted var filename: String?
|
||||||
|
@Persisted var _contentUrl: String = ""
|
||||||
|
@Persisted var mimeType: String?
|
||||||
|
@Persisted var size: Int = 0
|
||||||
|
|
||||||
|
var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString }
|
||||||
|
var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! }
|
||||||
|
var basePath: String? { self.filename }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, filename, contentUrl, mimeType, size, basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
filename = try? values.decode(String.self, forKey: .filename)
|
||||||
|
mimeType = try? values.decode(String.self, forKey: .mimeType)
|
||||||
|
size = try values.decode(Int.self, forKey: .size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(filename, forKey: .filename)
|
||||||
|
try container.encode(contentUrl, forKey: .contentUrl)
|
||||||
|
try container.encode(mimeType, forKey: .mimeType)
|
||||||
|
try container.encode(size, forKey: .size)
|
||||||
|
try container.encode(basePath, forKey: .basePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalFile {
|
||||||
|
convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = "\(libraryItemId)_\(filename.toBase64())"
|
||||||
|
self.filename = filename
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self._contentUrl = localUrl
|
||||||
|
self.size = fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var absolutePath: String {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: self._contentUrl)?.absoluteString ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAudioFile() -> Bool {
|
||||||
|
switch self.mimeType {
|
||||||
|
case "application/octet-stream",
|
||||||
|
"video/mp4":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return self.mimeType?.starts(with: "audio") ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//
|
||||||
|
// LocalLibraryItem.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalLibraryItem: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = "local_\(UUID().uuidString)"
|
||||||
|
@Persisted var basePath: String = ""
|
||||||
|
@Persisted var _contentUrl: String?
|
||||||
|
@Persisted var isInvalid: Bool = false
|
||||||
|
@Persisted var mediaType: String = ""
|
||||||
|
@Persisted var media: MediaType?
|
||||||
|
@Persisted var localFiles = List<LocalFile>()
|
||||||
|
@Persisted var _coverContentUrl: String?
|
||||||
|
@Persisted var isLocal: Bool = true
|
||||||
|
@Persisted var serverConnectionConfigId: String?
|
||||||
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var serverUserId: String?
|
||||||
|
@Persisted(indexed: true) var libraryItemId: String?
|
||||||
|
|
||||||
|
var contentUrl: String? {
|
||||||
|
if let path = _contentUrl {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentDirectory: URL? {
|
||||||
|
if let path = _contentUrl {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: path)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverContentUrl: String? {
|
||||||
|
if let path = self._coverContentUrl {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isBook: Bool { self.mediaType == "book" }
|
||||||
|
var isPodcast: Bool { self.mediaType == "podcast" }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
basePath = try values.decode(String.self, forKey: .basePath)
|
||||||
|
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
|
||||||
|
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||||
|
media = try? values.decode(MediaType.self, forKey: .media)
|
||||||
|
if let files = try? values.decode([LocalFile].self, forKey: .localFiles) {
|
||||||
|
localFiles.append(objectsIn: files)
|
||||||
|
}
|
||||||
|
isLocal = try values.decode(Bool.self, forKey: .isLocal)
|
||||||
|
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
|
||||||
|
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
|
||||||
|
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(basePath, forKey: .basePath)
|
||||||
|
try container.encode(contentUrl, forKey: .contentUrl)
|
||||||
|
try container.encode(isInvalid, forKey: .isInvalid)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(media, forKey: .media)
|
||||||
|
try container.encode(Array(localFiles), forKey: .localFiles)
|
||||||
|
try container.encode(coverContentUrl, forKey: .coverContentUrl)
|
||||||
|
try container.encode(isLocal, forKey: .isLocal)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(serverUserId, forKey: .serverUserId)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalLibraryItem {
|
||||||
|
convenience init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self._contentUrl = localUrl
|
||||||
|
self.mediaType = item.mediaType
|
||||||
|
self.localFiles.append(objectsIn: files)
|
||||||
|
self._coverContentUrl = coverPath
|
||||||
|
self.libraryItemId = item.id
|
||||||
|
self.serverConnectionConfigId = server.id
|
||||||
|
self.serverAddress = server.address
|
||||||
|
self.serverUserId = server.userId
|
||||||
|
|
||||||
|
// Link the audio tracks and files
|
||||||
|
linkLocalFiles(self.localFiles, fromMedia: item.media)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFiles(_ files: [LocalFile], item: LibraryItem) throws {
|
||||||
|
guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported }
|
||||||
|
self.localFiles.append(objectsIn: files.filter({ $0.isAudioFile() }))
|
||||||
|
linkLocalFiles(self.localFiles, fromMedia: item.media)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func linkLocalFiles(_ files: List<LocalFile>, fromMedia: MediaType?) {
|
||||||
|
guard let fromMedia = MediaType.detachCopy(of: fromMedia) else { return }
|
||||||
|
let fileMap = files.map { ($0.filename ?? "", $0.id) }
|
||||||
|
let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last })
|
||||||
|
if ( self.isBook ) {
|
||||||
|
for i in fromMedia.tracks.indices {
|
||||||
|
_ = fromMedia.tracks[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
|
||||||
|
}
|
||||||
|
} else if ( self.isPodcast ) {
|
||||||
|
let episodes = List<PodcastEpisode>()
|
||||||
|
for episode in fromMedia.episodes {
|
||||||
|
// Filter out episodes not downloaded
|
||||||
|
let episodeIsDownloaded = episode.audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) ?? false
|
||||||
|
if episodeIsDownloaded {
|
||||||
|
episodes.append(episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fromMedia.episodes = episodes
|
||||||
|
}
|
||||||
|
self.media = fromMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDuration() -> Double {
|
||||||
|
var total = 0.0
|
||||||
|
self.media?.tracks.enumerated().forEach { _, track in total += track.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPodcastEpisode(episodeId: String?) -> PodcastEpisode? {
|
||||||
|
guard self.isPodcast else { return nil }
|
||||||
|
guard let episodes = self.media?.episodes else { return nil }
|
||||||
|
return episodes.first(where: { $0.id == episodeId })
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession {
|
||||||
|
let localEpisodeId = episode?.id
|
||||||
|
let sessionId = "play_local_\(UUID().uuidString)"
|
||||||
|
|
||||||
|
// Get current progress from local media
|
||||||
|
let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id
|
||||||
|
let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId)
|
||||||
|
|
||||||
|
let mediaMetadata = Metadata.detachCopy(of: self.media?.metadata)
|
||||||
|
let chapters = List<Chapter>()
|
||||||
|
self.media?.chapters.forEach { chapter in chapters.append(Chapter.detachCopy(of: chapter)!) }
|
||||||
|
let authorName = mediaMetadata?.authorDisplayName
|
||||||
|
|
||||||
|
let audioTracks = List<AudioTrack>()
|
||||||
|
if let episode = episode, let track = episode.audioTrack {
|
||||||
|
audioTracks.append(AudioTrack.detachCopy(of: track)!)
|
||||||
|
} else if let tracks = self.media?.tracks {
|
||||||
|
tracks.forEach { t in audioTracks.append(AudioTrack.detachCopy(of: t)!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateNow = Date().timeIntervalSince1970 * 1000
|
||||||
|
return PlaybackSession(
|
||||||
|
id: sessionId,
|
||||||
|
userId: self.serverUserId,
|
||||||
|
libraryItemId: self.libraryItemId,
|
||||||
|
episodeId: episode?.serverEpisodeId,
|
||||||
|
mediaType: self.mediaType,
|
||||||
|
mediaMetadata: mediaMetadata,
|
||||||
|
chapters: chapters,
|
||||||
|
displayTitle: mediaMetadata?.title,
|
||||||
|
displayAuthor: authorName,
|
||||||
|
coverPath: self.coverContentUrl,
|
||||||
|
duration: self.getDuration(),
|
||||||
|
playMethod: PlayMethod.local.rawValue,
|
||||||
|
startedAt: dateNow,
|
||||||
|
updatedAt: dateNow,
|
||||||
|
timeListening: 0.0,
|
||||||
|
audioTracks: audioTracks,
|
||||||
|
currentTime: mediaProgress?.currentTime ?? 0.0,
|
||||||
|
libraryItem: nil,
|
||||||
|
localLibraryItem: self,
|
||||||
|
serverConnectionConfigId: self.serverConnectionConfigId,
|
||||||
|
serverAddress: self.serverAddress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.realm?.delete(self.localFiles)
|
||||||
|
self.realm?.delete(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//
|
||||||
|
// LocalMediaProgress.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalMediaProgress: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = ""
|
||||||
|
@Persisted(indexed: true) var localLibraryItemId: String = ""
|
||||||
|
@Persisted(indexed: true) var localEpisodeId: String?
|
||||||
|
@Persisted var duration: Double = 0
|
||||||
|
@Persisted var progress: Double = 0
|
||||||
|
@Persisted var currentTime: Double = 0
|
||||||
|
@Persisted var isFinished: Bool = false
|
||||||
|
@Persisted var lastUpdate: Double = 0
|
||||||
|
@Persisted var startedAt: Double = 0
|
||||||
|
@Persisted var finishedAt: Double?
|
||||||
|
// For local lib items from server to support server sync
|
||||||
|
@Persisted var serverConnectionConfigId: String?
|
||||||
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var serverUserId: String?
|
||||||
|
@Persisted(indexed: true) var libraryItemId: String?
|
||||||
|
@Persisted(indexed: true) var episodeId: String?
|
||||||
|
|
||||||
|
var progressPercent: Int { Int(self.progress * 100) }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
localLibraryItemId = try values.decode(String.self, forKey: .localLibraryItemId)
|
||||||
|
localEpisodeId = try values.decodeIfPresent(String.self, forKey: .localEpisodeId)
|
||||||
|
duration = try values.decode(Double.self, forKey: .duration)
|
||||||
|
progress = try values.decode(Double.self, forKey: .progress)
|
||||||
|
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||||
|
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
||||||
|
lastUpdate = try values.decode(Double.self, forKey: .lastUpdate)
|
||||||
|
startedAt = try values.decode(Double.self, forKey: .startedAt)
|
||||||
|
finishedAt = try values.decodeIfPresent(Double.self, forKey: .finishedAt)
|
||||||
|
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||||
|
serverUserId = try values.decodeIfPresent(String.self, forKey: .serverUserId)
|
||||||
|
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(localLibraryItemId, forKey: .localLibraryItemId)
|
||||||
|
try container.encode(localEpisodeId, forKey: .localEpisodeId)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
if progress.isNaN == false {
|
||||||
|
try container.encode(progress, forKey: .progress)
|
||||||
|
}
|
||||||
|
if currentTime.isNaN == false {
|
||||||
|
try container.encode(currentTime, forKey: .currentTime)
|
||||||
|
}
|
||||||
|
try container.encode(isFinished, forKey: .isFinished)
|
||||||
|
try container.encode(lastUpdate, forKey: .lastUpdate)
|
||||||
|
try container.encode(startedAt, forKey: .startedAt)
|
||||||
|
try container.encode(finishedAt, forKey: .finishedAt)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(serverUserId, forKey: .serverUserId)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalMediaProgress {
|
||||||
|
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = localLibraryItem.id
|
||||||
|
self.localLibraryItemId = localLibraryItem.id
|
||||||
|
self.libraryItemId = localLibraryItem.libraryItemId
|
||||||
|
|
||||||
|
self.serverAddress = localLibraryItem.serverAddress
|
||||||
|
self.serverUserId = localLibraryItem.serverUserId
|
||||||
|
self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId
|
||||||
|
|
||||||
|
self.duration = localLibraryItem.getDuration()
|
||||||
|
self.progress = 0.0
|
||||||
|
self.currentTime = 0.0
|
||||||
|
self.isFinished = false
|
||||||
|
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||||
|
self.startedAt = 0
|
||||||
|
self.finishedAt = nil
|
||||||
|
|
||||||
|
if let episode = episode {
|
||||||
|
self.id += "-\(episode.id)"
|
||||||
|
self.episodeId = episode.id
|
||||||
|
self.duration = episode.duration ?? 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) {
|
||||||
|
self.init(localLibraryItem: localLibraryItem, episode: episode)
|
||||||
|
self.duration = progress.duration
|
||||||
|
self.progress = progress.progress
|
||||||
|
self.currentTime = progress.currentTime
|
||||||
|
self.isFinished = progress.isFinished
|
||||||
|
self.lastUpdate = progress.lastUpdate
|
||||||
|
self.startedAt = progress.startedAt
|
||||||
|
self.finishedAt = progress.finishedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIsFinished(_ finished: Bool) throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
if self.isFinished != finished {
|
||||||
|
self.progress = finished ? 1.0 : 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.startedAt == 0 && finished {
|
||||||
|
self.startedAt = Date().timeIntervalSince1970 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isFinished = finished
|
||||||
|
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||||
|
self.finishedAt = finished ? lastUpdate : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromPlaybackSession(_ playbackSession: PlaybackSession) throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.currentTime = playbackSession.currentTime
|
||||||
|
self.progress = playbackSession.progress
|
||||||
|
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||||
|
self.isFinished = playbackSession.progress >= 100.0
|
||||||
|
self.finishedAt = self.isFinished ? self.lastUpdate : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.isFinished = serverMediaProgress.isFinished
|
||||||
|
self.progress = serverMediaProgress.progress
|
||||||
|
self.currentTime = serverMediaProgress.currentTime
|
||||||
|
self.duration = serverMediaProgress.duration
|
||||||
|
self.lastUpdate = serverMediaProgress.lastUpdate
|
||||||
|
self.finishedAt = serverMediaProgress.finishedAt
|
||||||
|
self.startedAt = serverMediaProgress.startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getLocalMediaProgressId(localLibraryItemId: String?, localEpisodeId: String?) -> String? {
|
||||||
|
if let itemId = localLibraryItemId, let episodeId = localEpisodeId {
|
||||||
|
return "\(itemId)-\(episodeId)"
|
||||||
|
} else if let itemId = localLibraryItemId {
|
||||||
|
return itemId
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) throws -> LocalMediaProgress? {
|
||||||
|
let localMediaProgressId = localMediaProgressId != nil ? localMediaProgressId : LocalMediaProgress.getLocalMediaProgressId(localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||||
|
|
||||||
|
let realm = try Realm()
|
||||||
|
return try realm.write { () -> LocalMediaProgress? in
|
||||||
|
if let localMediaProgressId = localMediaProgressId {
|
||||||
|
// Check if it existing in the database, if not, we need to create it
|
||||||
|
if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) {
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let localLibraryItemId = localLibraryItemId {
|
||||||
|
guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil }
|
||||||
|
let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId)
|
||||||
|
let progress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode)
|
||||||
|
realm.add(progress)
|
||||||
|
return progress
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//
|
||||||
|
// LocalPodcastEpisode.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalPodcastEpisode: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
|
@Persisted var index: Int = 0
|
||||||
|
@Persisted var episode: String?
|
||||||
|
@Persisted var episodeType: String?
|
||||||
|
@Persisted var title: String = "Unknown"
|
||||||
|
@Persisted var subtitle: String?
|
||||||
|
@Persisted var desc: String?
|
||||||
|
@Persisted var audioFile: AudioFile?
|
||||||
|
@Persisted var audioTrack: AudioTrack?
|
||||||
|
@Persisted var duration: Double = 0
|
||||||
|
@Persisted var size: Int = 0
|
||||||
|
@Persisted(indexed: true) var serverEpisodeId: String?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// AudioFile.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class AudioFile: EmbeddedObject, Codable {
|
||||||
|
@Persisted var index: Int?
|
||||||
|
@Persisted var ino: String = ""
|
||||||
|
@Persisted var metadata: FileMetadata?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case index, ino, metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
index = try? values.decode(Int.self, forKey: .index)
|
||||||
|
ino = try values.decode(String.self, forKey: .ino)
|
||||||
|
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(index, forKey: .index)
|
||||||
|
try container.encode(ino, forKey: .ino)
|
||||||
|
try container.encode(metadata, forKey: .metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// AudioTrack.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class AudioTrack: EmbeddedObject, Codable {
|
||||||
|
@Persisted var index: Int?
|
||||||
|
@Persisted var startOffset: Double?
|
||||||
|
@Persisted var duration: Double = 0
|
||||||
|
@Persisted var title: String?
|
||||||
|
@Persisted var contentUrl: String?
|
||||||
|
@Persisted var mimeType: String = ""
|
||||||
|
@Persisted var metadata: FileMetadata?
|
||||||
|
@Persisted var localFileId: String?
|
||||||
|
@Persisted var serverIndex: Int?
|
||||||
|
|
||||||
|
var endOffset: Double? {
|
||||||
|
if let startOffset = startOffset {
|
||||||
|
return startOffset + duration
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
index = try? values.decode(Int.self, forKey: .index)
|
||||||
|
startOffset = try? values.decode(Double.self, forKey: .startOffset)
|
||||||
|
duration = try values.decode(Double.self, forKey: .duration)
|
||||||
|
title = try? values.decode(String.self, forKey: .title)
|
||||||
|
contentUrl = try? values.decode(String.self, forKey: .contentUrl)
|
||||||
|
mimeType = try values.decode(String.self, forKey: .mimeType)
|
||||||
|
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
|
||||||
|
localFileId = try? values.decodeIfPresent(String.self, forKey: .localFileId)
|
||||||
|
serverIndex = try? values.decode(Int.self, forKey: .serverIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(index, forKey: .index)
|
||||||
|
try container.encode(startOffset, forKey: .startOffset)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(title, forKey: .title)
|
||||||
|
try container.encode(contentUrl, forKey: .contentUrl)
|
||||||
|
try container.encode(mimeType, forKey: .mimeType)
|
||||||
|
try container.encode(metadata, forKey: .metadata)
|
||||||
|
try container.encode(localFileId, forKey: .localFileId)
|
||||||
|
try container.encode(serverIndex, forKey: .serverIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioTrack {
|
||||||
|
func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) -> Bool {
|
||||||
|
if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] {
|
||||||
|
self.localFileId = localFileId
|
||||||
|
self.serverIndex = serverIndex
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLocalFile() -> LocalFile? {
|
||||||
|
guard let localFileId = self.localFileId else { return nil }
|
||||||
|
return Database.shared.getLocalFile(localFileId: localFileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// Author.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class Author: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var name: String = "Unknown"
|
||||||
|
@Persisted var coverPath: String?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, name, coverPath
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
name = try values.decode(String.self, forKey: .name)
|
||||||
|
coverPath = try? values.decode(String.self, forKey: .coverPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(coverPath, forKey: .coverPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// Chapter.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class Chapter: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: Int = 0
|
||||||
|
@Persisted var start: Double = 0
|
||||||
|
@Persisted var end: Double = 0
|
||||||
|
@Persisted var title: String?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, start, end, title
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(Int.self, forKey: .id)
|
||||||
|
start = try values.decode(Double.self, forKey: .start)
|
||||||
|
end = try values.decode(Double.self, forKey: .end)
|
||||||
|
title = try? values.decode(String.self, forKey: .title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(start, forKey: .start)
|
||||||
|
try container.encode(end, forKey: .end)
|
||||||
|
try container.encode(title, forKey: .title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// FileMetadata.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class FileMetadata: EmbeddedObject, Codable {
|
||||||
|
@Persisted var filename: String = ""
|
||||||
|
@Persisted var ext: String = ""
|
||||||
|
@Persisted var path: String = ""
|
||||||
|
@Persisted var relPath: String = ""
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case filename, ext, path, relPath
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
filename = try values.decode(String.self, forKey: .filename)
|
||||||
|
ext = try values.decode(String.self, forKey: .ext)
|
||||||
|
path = try values.decode(String.self, forKey: .path)
|
||||||
|
relPath = try values.decode(String.self, forKey: .relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(filename, forKey: .filename)
|
||||||
|
try container.encode(ext, forKey: .ext)
|
||||||
|
try container.encode(path, forKey: .path)
|
||||||
|
try container.encode(relPath, forKey: .relPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// Folder.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class Folder: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var fullPath: String = ""
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, fullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
fullPath = try values.decode(String.self, forKey: .fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(fullPath, forKey: .fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//
|
||||||
|
// Library.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class Library: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var name: String = "Unknown"
|
||||||
|
@Persisted var folders = List<Folder>()
|
||||||
|
@Persisted var icon: String = ""
|
||||||
|
@Persisted var mediaType: String = ""
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, name, folders, icon, mediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
name = try values.decode(String.self, forKey: .name)
|
||||||
|
if let folderList = try? values.decode([Folder].self, forKey: .folders) {
|
||||||
|
folders.append(objectsIn: folderList)
|
||||||
|
}
|
||||||
|
icon = try values.decode(String.self, forKey: .icon)
|
||||||
|
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(folders, forKey: .folders)
|
||||||
|
try container.encode(icon, forKey: .icon)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// LibraryFile.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LibraryFile: EmbeddedObject, Codable {
|
||||||
|
@Persisted var ino: String = ""
|
||||||
|
@Persisted var metadata: FileMetadata?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case ino, metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
ino = try values.decode(String.self, forKey: .ino)
|
||||||
|
metadata = try values.decode(FileMetadata.self, forKey: .metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(ino, forKey: .ino)
|
||||||
|
try container.encode(metadata, forKey: .metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//
|
||||||
|
// LibraryItem.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LibraryItem: Object, Codable {
|
||||||
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var ino: String = ""
|
||||||
|
@Persisted var libraryId: String = ""
|
||||||
|
@Persisted var folderId: String = ""
|
||||||
|
@Persisted var path: String = ""
|
||||||
|
@Persisted var relPath: String = ""
|
||||||
|
@Persisted var isFile: Bool = true
|
||||||
|
@Persisted var mtimeMs: Int = 0
|
||||||
|
@Persisted var ctimeMs: Int = 0
|
||||||
|
@Persisted var birthtimeMs: Int = 0
|
||||||
|
@Persisted var addedAt: Int = 0
|
||||||
|
@Persisted var updatedAt: Int = 0
|
||||||
|
@Persisted var lastScan: Int?
|
||||||
|
@Persisted var scanVersion: String?
|
||||||
|
@Persisted var isMissing: Bool = false
|
||||||
|
@Persisted var isInvalid: Bool = false
|
||||||
|
@Persisted var mediaType: String = ""
|
||||||
|
@Persisted var media: MediaType?
|
||||||
|
@Persisted var libraryFiles = List<LibraryFile>()
|
||||||
|
@Persisted var userMediaProgress: MediaProgress?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, ino, libraryId, folderId, path, relPath, isFile, mtimeMs, ctimeMs, birthtimeMs, addedAt, updatedAt, lastScan, scanVersion, isMissing, isInvalid, mediaType, media, libraryFiles, userMediaProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
ino = try values.decode(String.self, forKey: .ino)
|
||||||
|
libraryId = try values.decode(String.self, forKey: .libraryId)
|
||||||
|
folderId = try values.decode(String.self, forKey: .folderId)
|
||||||
|
path = try values.decode(String.self, forKey: .path)
|
||||||
|
relPath = try values.decode(String.self, forKey: .relPath)
|
||||||
|
isFile = try values.decode(Bool.self, forKey: .isFile)
|
||||||
|
mtimeMs = try values.decode(Int.self, forKey: .mtimeMs)
|
||||||
|
ctimeMs = try values.decode(Int.self, forKey: .ctimeMs)
|
||||||
|
birthtimeMs = try values.decode(Int.self, forKey: .birthtimeMs)
|
||||||
|
addedAt = try values.decode(Int.self, forKey: .addedAt)
|
||||||
|
updatedAt = try values.decode(Int.self, forKey: .updatedAt)
|
||||||
|
lastScan = try? values.decode(Int.self, forKey: .lastScan)
|
||||||
|
scanVersion = try? values.decode(String.self, forKey: .scanVersion)
|
||||||
|
isMissing = try values.decode(Bool.self, forKey: .isMissing)
|
||||||
|
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
|
||||||
|
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||||
|
media = try? values.decode(MediaType.self, forKey: .media)
|
||||||
|
if let files = try? values.decode([LibraryFile].self, forKey: .libraryFiles) {
|
||||||
|
libraryFiles.append(objectsIn: files)
|
||||||
|
}
|
||||||
|
userMediaProgress = try? values.decode(MediaProgress.self, forKey: .userMediaProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(ino, forKey: .ino)
|
||||||
|
try container.encode(libraryId, forKey: .libraryId)
|
||||||
|
try container.encode(folderId, forKey: .folderId)
|
||||||
|
try container.encode(path, forKey: .path)
|
||||||
|
try container.encode(relPath, forKey: .relPath)
|
||||||
|
try container.encode(isFile, forKey: .isFile)
|
||||||
|
try container.encode(mtimeMs, forKey: .mtimeMs)
|
||||||
|
try container.encode(ctimeMs, forKey: .ctimeMs)
|
||||||
|
try container.encode(birthtimeMs, forKey: .birthtimeMs)
|
||||||
|
try container.encode(addedAt, forKey: .addedAt)
|
||||||
|
try container.encode(updatedAt, forKey: .updatedAt)
|
||||||
|
try container.encode(lastScan, forKey: .lastScan)
|
||||||
|
try container.encode(scanVersion, forKey: .scanVersion)
|
||||||
|
try container.encode(isMissing, forKey: .isMissing)
|
||||||
|
try container.encode(isInvalid, forKey: .isInvalid)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(media, forKey: .media)
|
||||||
|
try container.encode(Array(libraryFiles), forKey: .libraryFiles)
|
||||||
|
try container.encode(userMediaProgress, forKey: .userMediaProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// MediaProgress.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class MediaProgress: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var libraryItemId: String = ""
|
||||||
|
@Persisted var episodeId: String?
|
||||||
|
@Persisted var duration: Double = 0
|
||||||
|
@Persisted var progress: Double = 0
|
||||||
|
@Persisted var currentTime: Double = 0
|
||||||
|
@Persisted var isFinished: Bool = false
|
||||||
|
@Persisted var lastUpdate: Double = 0
|
||||||
|
@Persisted var startedAt: Double = 0
|
||||||
|
@Persisted var finishedAt: Double?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||||
|
duration = try values.doubleOrStringDecoder(key: .duration)
|
||||||
|
progress = try values.doubleOrStringDecoder(key: .progress)
|
||||||
|
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
|
||||||
|
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
||||||
|
lastUpdate = try values.doubleOrStringDecoder(key: .lastUpdate)
|
||||||
|
startedAt = try values.doubleOrStringDecoder(key: .startedAt)
|
||||||
|
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(progress, forKey: .progress)
|
||||||
|
try container.encode(currentTime, forKey: .currentTime)
|
||||||
|
try container.encode(isFinished, forKey: .isFinished)
|
||||||
|
try container.encode(lastUpdate, forKey: .lastUpdate)
|
||||||
|
try container.encode(startedAt, forKey: .startedAt)
|
||||||
|
try container.encode(finishedAt, forKey: .finishedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//
|
||||||
|
// MediaType.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class MediaType: EmbeddedObject, Codable {
|
||||||
|
@Persisted var libraryItemId: String?
|
||||||
|
@Persisted var metadata: Metadata?
|
||||||
|
@Persisted var coverPath: String?
|
||||||
|
@Persisted var tags = List<String>()
|
||||||
|
@Persisted var audioFiles = List<AudioFile>()
|
||||||
|
@Persisted var chapters = List<Chapter>()
|
||||||
|
@Persisted var tracks = List<AudioTrack>()
|
||||||
|
@Persisted var size: Int?
|
||||||
|
@Persisted var duration: Double?
|
||||||
|
@Persisted var episodes = List<PodcastEpisode>()
|
||||||
|
@Persisted var autoDownloadEpisodes: Bool?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case libraryItemId, metadata, coverPath, tags, audioFiles, chapters, tracks, size, duration, episodes, autoDownloadEpisodes
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||||
|
metadata = try? values.decode(Metadata.self, forKey: .metadata)
|
||||||
|
coverPath = try? values.decode(String.self, forKey: .coverPath)
|
||||||
|
if let tagList = try? values.decode([String].self, forKey: .tags) {
|
||||||
|
tags.append(objectsIn: tagList)
|
||||||
|
}
|
||||||
|
if let fileList = try? values.decode([AudioFile].self, forKey: .audioFiles) {
|
||||||
|
audioFiles.append(objectsIn: fileList)
|
||||||
|
}
|
||||||
|
if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) {
|
||||||
|
chapters.append(objectsIn: chapterList)
|
||||||
|
}
|
||||||
|
if let trackList = try? values.decode([AudioTrack].self, forKey: .tracks) {
|
||||||
|
tracks.append(objectsIn: trackList)
|
||||||
|
}
|
||||||
|
size = try? values.decode(Int.self, forKey: .size)
|
||||||
|
duration = try? values.decode(Double.self, forKey: .duration)
|
||||||
|
if let episodeList = try? values.decode([PodcastEpisode].self, forKey: .episodes) {
|
||||||
|
episodes.append(objectsIn: episodeList)
|
||||||
|
}
|
||||||
|
autoDownloadEpisodes = try? values.decode(Bool.self, forKey: .autoDownloadEpisodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(metadata, forKey: .metadata)
|
||||||
|
try container.encode(coverPath, forKey: .coverPath)
|
||||||
|
try container.encode(Array(tags), forKey: .tags)
|
||||||
|
try container.encode(Array(audioFiles), forKey: .audioFiles)
|
||||||
|
try container.encode(Array(chapters), forKey: .chapters)
|
||||||
|
try container.encode(Array(tracks), forKey: .tracks)
|
||||||
|
try container.encode(size, forKey: .size)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(Array(episodes), forKey: .episodes)
|
||||||
|
try container.encode(autoDownloadEpisodes, forKey: .autoDownloadEpisodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// Metadata.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class Metadata: EmbeddedObject, Codable {
|
||||||
|
@Persisted var title: String = "Unknown"
|
||||||
|
@Persisted var subtitle: String?
|
||||||
|
@Persisted var authors = List<Author>()
|
||||||
|
@Persisted var narrators = List<String>()
|
||||||
|
@Persisted var genres = List<String>()
|
||||||
|
@Persisted var publishedYear: String?
|
||||||
|
@Persisted var publishedDate: String?
|
||||||
|
@Persisted var publisher: String?
|
||||||
|
@Persisted var desc: String?
|
||||||
|
@Persisted var isbn: String?
|
||||||
|
@Persisted var asin: String?
|
||||||
|
@Persisted var language: String?
|
||||||
|
@Persisted var explicit: Bool = false
|
||||||
|
@Persisted var authorName: String?
|
||||||
|
@Persisted var authorNameLF: String?
|
||||||
|
@Persisted var narratorName: String?
|
||||||
|
@Persisted var seriesName: String?
|
||||||
|
@Persisted var feedUrl: String?
|
||||||
|
|
||||||
|
var authorDisplayName: String { self.authorName ?? "Unknown" }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case title,
|
||||||
|
subtitle,
|
||||||
|
authors,
|
||||||
|
narrators,
|
||||||
|
genres,
|
||||||
|
publishedYear,
|
||||||
|
publishedDate,
|
||||||
|
publisher,
|
||||||
|
desc = "description", // Fixes a collision with the base Swift object's field "description"
|
||||||
|
isbn,
|
||||||
|
asin,
|
||||||
|
language,
|
||||||
|
explicit,
|
||||||
|
authorName,
|
||||||
|
authorNameLF,
|
||||||
|
narratorName,
|
||||||
|
seriesName,
|
||||||
|
feedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
title = try values.decode(String.self, forKey: .title)
|
||||||
|
subtitle = try? values.decode(String.self, forKey: .subtitle)
|
||||||
|
if let authorList = try? values.decode([Author].self, forKey: .authors) {
|
||||||
|
authors.append(objectsIn: authorList)
|
||||||
|
}
|
||||||
|
if let narratorList = try? values.decode([String].self, forKey: .narrators) {
|
||||||
|
narrators.append(objectsIn: narratorList)
|
||||||
|
}
|
||||||
|
if let genreList = try? values.decode([String].self, forKey: .genres) {
|
||||||
|
genres.append(objectsIn: genreList)
|
||||||
|
}
|
||||||
|
publishedYear = try? values.decode(String.self, forKey: .publishedYear)
|
||||||
|
publishedDate = try? values.decode(String.self, forKey: .publishedDate)
|
||||||
|
publisher = try? values.decode(String.self, forKey: .publisher)
|
||||||
|
desc = try? values.decode(String.self, forKey: .desc)
|
||||||
|
isbn = try? values.decode(String.self, forKey: .isbn)
|
||||||
|
asin = try? values.decode(String.self, forKey: .asin)
|
||||||
|
language = try? values.decode(String.self, forKey: .language)
|
||||||
|
explicit = try values.decode(Bool.self, forKey: .explicit)
|
||||||
|
authorName = try? values.decode(String.self, forKey: .authorName)
|
||||||
|
authorNameLF = try? values.decode(String.self, forKey: .authorNameLF)
|
||||||
|
narratorName = try? values.decode(String.self, forKey: .narratorName)
|
||||||
|
seriesName = try? values.decode(String.self, forKey: .seriesName)
|
||||||
|
feedUrl = try? values.decode(String.self, forKey: .feedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(title, forKey: .title)
|
||||||
|
try container.encode(subtitle, forKey: .subtitle)
|
||||||
|
try container.encode(Array(authors), forKey: .authors)
|
||||||
|
try container.encode(Array(narrators), forKey: .narrators)
|
||||||
|
try container.encode(Array(genres), forKey: .genres)
|
||||||
|
try container.encode(publishedYear, forKey: .publishedYear)
|
||||||
|
try container.encode(publishedDate, forKey: .publishedDate)
|
||||||
|
try container.encode(publisher, forKey: .publisher)
|
||||||
|
try container.encode(desc, forKey: .desc)
|
||||||
|
try container.encode(isbn, forKey: .isbn)
|
||||||
|
try container.encode(asin, forKey: .asin)
|
||||||
|
try container.encode(language, forKey: .language)
|
||||||
|
try container.encode(explicit, forKey: .explicit)
|
||||||
|
try container.encode(authorName, forKey: .authorName)
|
||||||
|
try container.encode(authorNameLF, forKey: .authorNameLF)
|
||||||
|
try container.encode(narratorName, forKey: .narratorName)
|
||||||
|
try container.encode(seriesName, forKey: .seriesName)
|
||||||
|
try container.encode(feedUrl, forKey: .feedUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//
|
||||||
|
// PodcastEpisode.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class PodcastEpisode: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var index: Int?
|
||||||
|
@Persisted var episode: String?
|
||||||
|
@Persisted var episodeType: String?
|
||||||
|
@Persisted var title: String = "Unknown"
|
||||||
|
@Persisted var subtitle: String?
|
||||||
|
@Persisted var desc: String?
|
||||||
|
@Persisted var audioFile: AudioFile?
|
||||||
|
@Persisted var audioTrack: AudioTrack?
|
||||||
|
@Persisted var duration: Double?
|
||||||
|
@Persisted var size: Int?
|
||||||
|
var serverEpisodeId: String { self.id }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id,
|
||||||
|
index,
|
||||||
|
episode,
|
||||||
|
episodeType,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
desc = "description", // Fixes a collision with the base Swift object's field "description"
|
||||||
|
audioFile,
|
||||||
|
audioTrack,
|
||||||
|
duration,
|
||||||
|
size,
|
||||||
|
serverEpisodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
index = try? values.decode(Int.self, forKey: .index)
|
||||||
|
episode = try? values.decode(String.self, forKey: .episode)
|
||||||
|
episodeType = try? values.decode(String.self, forKey: .episodeType)
|
||||||
|
title = try values.decode(String.self, forKey: .title)
|
||||||
|
subtitle = try? values.decode(String.self, forKey: .subtitle)
|
||||||
|
desc = try? values.decode(String.self, forKey: .desc)
|
||||||
|
audioFile = try? values.decode(AudioFile.self, forKey: .audioFile)
|
||||||
|
audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack)
|
||||||
|
duration = try? values.decode(Double.self, forKey: .duration)
|
||||||
|
size = try? values.decode(Int.self, forKey: .size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(index, forKey: .index)
|
||||||
|
try container.encode(episode, forKey: .episode)
|
||||||
|
try container.encode(episodeType, forKey: .episodeType)
|
||||||
|
try container.encode(title, forKey: .title)
|
||||||
|
try container.encode(subtitle, forKey: .subtitle)
|
||||||
|
try container.encode(desc, forKey: .desc)
|
||||||
|
try container.encode(audioFile, forKey: .audioFile)
|
||||||
|
try container.encode(audioTrack, forKey: .audioTrack)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(size, forKey: .size)
|
||||||
|
try container.encode(serverEpisodeId, forKey: .serverEpisodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,22 +32,31 @@ 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 playbackSession: PlaybackSession
|
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(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
||||||
self.playWhenReady = playWhenReady
|
self.playWhenReady = playWhenReady
|
||||||
self.initialPlaybackRate = playbackRate
|
self.initialPlaybackRate = playbackRate
|
||||||
self.audioPlayer = AVQueuePlayer()
|
self.audioPlayer = AVQueuePlayer()
|
||||||
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
|
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
|
||||||
self.playbackSession = playbackSession
|
self.sessionId = sessionId
|
||||||
self.status = -1
|
self.status = -1
|
||||||
self.rate = 0.0
|
self.rate = 0.0
|
||||||
self.tmpRate = playbackRate
|
self.tmpRate = playbackRate
|
||||||
@@ -56,13 +66,23 @@ class AudioPlayer: NSObject {
|
|||||||
initAudioSession()
|
initAudioSession()
|
||||||
setupRemoteTransportControls()
|
setupRemoteTransportControls()
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -75,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)
|
||||||
@@ -99,13 +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 getItemIndexForTime(time:Double) -> Int {
|
public func isInitialized() -> Bool {
|
||||||
|
return self.status != -1
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getPlaybackSession() -> PlaybackSession? {
|
||||||
|
return Database.shared.getPlaybackSession(id: self.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getItemIndexForTime(time:Double) -> Int {
|
||||||
|
guard let playbackSession = self.getPlaybackSession() else { return 0 }
|
||||||
for index in 0..<self.allPlayerItems.count {
|
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
|
||||||
@@ -117,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
|
||||||
@@ -129,96 +220,175 @@ 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
|
||||||
if (playerItem.status == .readyToPlay) {
|
self?.handleQueueItemStatus(playerItem: playerItem)
|
||||||
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
|
||||||
self.updateNowPlaying()
|
|
||||||
|
|
||||||
let firstReady = self.status < 0
|
|
||||||
self.status = 0
|
|
||||||
if self.playWhenReady {
|
|
||||||
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
|
|
||||||
self.playWhenReady = false
|
|
||||||
self.play()
|
|
||||||
} else if (firstReady) { // Only seek on first readyToPlay
|
|
||||||
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
|
|
||||||
}
|
|
||||||
} 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) {
|
||||||
if allowSeekBack {
|
guard self.isInitialized() else { return }
|
||||||
let diffrence = Date.timeIntervalSinceReferenceDate - lastPlayTime
|
guard let session = self.getPlaybackSession() else {
|
||||||
var time: Int?
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||||
|
return
|
||||||
if lastPlayTime == 0 {
|
|
||||||
time = 5
|
|
||||||
} else if diffrence < 6 {
|
|
||||||
time = 2
|
|
||||||
} else if diffrence < 12 {
|
|
||||||
time = 10
|
|
||||||
} else if diffrence < 30 {
|
|
||||||
time = 15
|
|
||||||
} else if diffrence < 180 {
|
|
||||||
time = 20
|
|
||||||
} else if diffrence < 3600 {
|
|
||||||
time = 25
|
|
||||||
} else {
|
|
||||||
time = 29
|
|
||||||
}
|
|
||||||
|
|
||||||
if time != nil {
|
|
||||||
seek(getCurrentTime() - Double(time!), from: "play")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lastPlayTime = Date.timeIntervalSinceReferenceDate
|
|
||||||
|
|
||||||
self.audioPlayer.play()
|
|
||||||
self.status = 1
|
|
||||||
self.rate = self.tmpRate
|
|
||||||
self.audioPlayer.rate = self.tmpRate
|
|
||||||
|
|
||||||
updateNowPlaying()
|
// Determine where we are starting playback
|
||||||
|
let lastPlayed = (session.updatedAt ?? 0)/1000
|
||||||
|
let currentTime = allowSeekBack ? calculateSeekBackTimeAtCurrentTime(session.currentTime, lastPlayed: lastPlayed) : session.currentTime
|
||||||
|
|
||||||
|
// Sync our new playback position
|
||||||
|
Task { await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: false) }
|
||||||
|
|
||||||
|
// Start playback, with a seek, for as smooth a scrub bar start as possible
|
||||||
|
let currentTrackStartOffset = session.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
|
||||||
|
let seekTime = currentTime - currentTrackStartOffset
|
||||||
|
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] completed in
|
||||||
|
guard completed else { return }
|
||||||
|
self?.resumePlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateSeekBackTimeAtCurrentTime(_ currentTime: Double, lastPlayed: Double) -> Double {
|
||||||
|
let difference = Date.timeIntervalSinceReferenceDate - lastPlayed
|
||||||
|
var time: Double = 0
|
||||||
|
|
||||||
|
// Scale seek back time based on how long since last play
|
||||||
|
if lastPlayed == 0 {
|
||||||
|
time = 5
|
||||||
|
} else if difference < 6 {
|
||||||
|
time = 2
|
||||||
|
} else if difference < 12 {
|
||||||
|
time = 10
|
||||||
|
} else if difference < 30 {
|
||||||
|
time = 15
|
||||||
|
} else if difference < 180 {
|
||||||
|
time = 20
|
||||||
|
} else if difference < 3600 {
|
||||||
|
time = 25
|
||||||
|
} else {
|
||||||
|
time = 29
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wind the clock back
|
||||||
|
return currentTime - time
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumePlayback() {
|
||||||
|
NSLog("PLAY: Resuming playback")
|
||||||
|
|
||||||
|
// Stop the paused timer
|
||||||
|
self.stopPausedTimer()
|
||||||
|
|
||||||
|
self.markAudioSessionAs(active: true)
|
||||||
|
self.audioPlayer.play()
|
||||||
|
self.audioPlayer.rate = self.tmpRate
|
||||||
|
self.status = 1
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
self.updateNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pause() {
|
public func pause() {
|
||||||
|
guard self.isInitialized() else { return }
|
||||||
|
|
||||||
|
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 currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
|
guard let playbackSession = self.getPlaybackSession() else { return }
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
self.playbackSession.currentTime = to
|
try? playbackSession.update {
|
||||||
|
playbackSession.currentTime = to
|
||||||
|
}
|
||||||
|
|
||||||
self.playWhenReady = continuePlaying // Only playWhenReady if already playing
|
self.playWhenReady = continuePlaying // Only playWhenReady if already playing
|
||||||
self.status = -1
|
self.status = -1
|
||||||
@@ -231,54 +401,72 @@ class AudioPlayer: NSObject {
|
|||||||
|
|
||||||
setupQueueItemStatusObserver()
|
setupQueueItemStatusObserver()
|
||||||
} else {
|
} else {
|
||||||
NSLog("Seeking in current item \(to)")
|
NSLog("SEEK: Seeking in current item \(to)")
|
||||||
let currentTrackStartOffset = self.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? {
|
||||||
|
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 {
|
|
||||||
return self.playbackSession.playMethod
|
public func getPlayMethod() -> Int? {
|
||||||
|
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||||
|
return playbackSession.playMethod
|
||||||
}
|
}
|
||||||
public func getPlaybackSession() -> PlaybackSession {
|
|
||||||
return self.playbackSession
|
public func getPlaybackSessionId() -> String {
|
||||||
|
return self.sessionId
|
||||||
}
|
}
|
||||||
public func getDuration() -> Double {
|
|
||||||
|
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? {
|
||||||
|
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 ?? ""
|
||||||
@@ -286,6 +474,17 @@ class AudioPlayer: NSObject {
|
|||||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||||
let url = URL(string: urlstr)!
|
let url = URL(string: urlstr)!
|
||||||
return AVURLAsset(url: url)
|
return AVURLAsset(url: url)
|
||||||
|
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
|
||||||
|
guard let localFile = track.getLocalFile() else {
|
||||||
|
// Worst case we can stream the file
|
||||||
|
NSLog("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
|
||||||
|
let filename = track.metadata?.filename ?? ""
|
||||||
|
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||||
|
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||||
|
let url = URL(string: urlstr)!
|
||||||
|
return AVURLAsset(url: url)
|
||||||
|
}
|
||||||
|
return AVURLAsset(url: localFile.contentPath)
|
||||||
} else { // HLS Transcode
|
} else { // HLS Transcode
|
||||||
let headers: [String: String] = [
|
let headers: [String: String] = [
|
||||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
@@ -296,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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,42 +6,46 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
class PlayerHandler {
|
class PlayerHandler {
|
||||||
private static var player: AudioPlayer?
|
private static var player: AudioPlayer?
|
||||||
private static var session: PlaybackSession?
|
|
||||||
private static var timer: Timer?
|
|
||||||
private static var lastSyncTime:Double = 0.0
|
|
||||||
|
|
||||||
public static var sleepTimerChapterStopTime: Int? = nil
|
public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) {
|
||||||
private static var _remainingSleepTime: Int? = nil
|
guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return }
|
||||||
public static var remainingSleepTime: Int? {
|
|
||||||
get {
|
// Clean up the existing player
|
||||||
return _remainingSleepTime
|
if player != nil {
|
||||||
}
|
player?.destroy()
|
||||||
set(time) {
|
player = nil
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup and sync old sessions
|
||||||
|
cleanupOldSessions(currentSessionId: sessionId)
|
||||||
|
Task { await PlayerProgress.shared.syncToServer() }
|
||||||
|
|
||||||
|
// Set now playing info
|
||||||
|
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
|
||||||
|
|
||||||
|
// Create the audio player
|
||||||
|
player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func stopPlayback() {
|
||||||
|
// Pause playback first, so we can sync our current progress
|
||||||
|
player?.pause()
|
||||||
|
|
||||||
|
player?.destroy()
|
||||||
|
player = nil
|
||||||
|
|
||||||
|
cleanupOldSessions(currentSessionId: nil)
|
||||||
|
|
||||||
|
NowPlayingInfo.shared.reset()
|
||||||
}
|
}
|
||||||
private static var listeningTimePassedSinceLastSync: Double = 0.0
|
|
||||||
private static var lastSyncReport: PlaybackReport?
|
|
||||||
|
|
||||||
public static var paused: Bool {
|
public static var paused: Bool {
|
||||||
get {
|
get {
|
||||||
guard let player = player else {
|
guard let player = player else { return true }
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return player.rate == 0.0
|
return player.rate == 0.0
|
||||||
}
|
}
|
||||||
set(paused) {
|
set(paused) {
|
||||||
@@ -53,131 +57,104 @@ class PlayerHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
|
|
||||||
if player != nil {
|
|
||||||
player?.destroy()
|
|
||||||
player = 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))
|
|
||||||
|
|
||||||
self.session = session
|
|
||||||
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
|
||||||
|
|
||||||
DispatchQueue.runOnMainQueue {
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
|
||||||
self.tick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public static func stopPlayback() {
|
|
||||||
player?.destroy()
|
|
||||||
player = nil
|
|
||||||
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
|
|
||||||
NowPlayingInfo.shared.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func getCurrentTime() -> Double? {
|
public static func getCurrentTime() -> Double? {
|
||||||
self.player?.getCurrentTime()
|
self.player?.getCurrentTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func setPlaybackSpeed(speed: Float) {
|
public static func setPlaybackSpeed(speed: Float) {
|
||||||
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? {
|
||||||
self.player?.getPlaybackSession()
|
guard let player = player else { return nil }
|
||||||
|
guard player.isInitialized() else { return nil }
|
||||||
|
|
||||||
|
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] {
|
|
||||||
DispatchQueue.main.async {
|
public static func getMetdata() -> [String: Any]? {
|
||||||
syncProgress()
|
guard let player = player else { return nil }
|
||||||
}
|
guard player.isInitialized() else { return nil }
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"duration": player?.getDuration() ?? 0,
|
"duration": player.getDuration(),
|
||||||
"currentTime": player?.getCurrentTime() ?? 0,
|
"currentTime": player.getCurrentTime(),
|
||||||
"playerState": !paused,
|
"playerState": !paused,
|
||||||
"currentRate": player?.rate ?? 0,
|
"currentRate": player.rate,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func tick() {
|
// MARK: - Helper logic
|
||||||
if !paused {
|
|
||||||
listeningTimePassedSinceLastSync += 1
|
private static func cleanupOldSessions(currentSessionId: String?) {
|
||||||
|
do {
|
||||||
if remainingSleepTime != nil {
|
let realm = try Realm()
|
||||||
if sleepTimerChapterStopTime != nil {
|
let oldSessions = realm.objects(PlaybackSession.self) .where({
|
||||||
let timeUntilChapterEnd = Double(sleepTimerChapterStopTime ?? 0) - (getCurrentTime() ?? 0)
|
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
if timeUntilChapterEnd <= 0 {
|
})
|
||||||
paused = true
|
try realm.write {
|
||||||
remainingSleepTime = nil
|
for s in oldSessions {
|
||||||
} else {
|
if s.id != currentSessionId {
|
||||||
remainingSleepTime = Int(timeUntilChapterEnd.rounded())
|
s.isActiveSession = false
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if remainingSleepTime! <= 0 {
|
|
||||||
paused = true
|
|
||||||
}
|
|
||||||
remainingSleepTime! -= 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
debugPrint("Failed to cleanup sessions")
|
||||||
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if listeningTimePassedSinceLastSync >= 5 {
|
|
||||||
syncProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public static func syncProgress() {
|
|
||||||
if session == nil { return }
|
|
||||||
guard let player = player else { return }
|
|
||||||
|
|
||||||
let playerCurrentTime = player.getCurrentTime()
|
|
||||||
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
|
|
||||||
// No need to syncProgress
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSyncTime = Date().timeIntervalSince1970 // seconds
|
|
||||||
|
|
||||||
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
|
|
||||||
|
|
||||||
session!.currentTime = playerCurrentTime
|
|
||||||
listeningTimePassedSinceLastSync = 0
|
|
||||||
lastSyncReport = report
|
|
||||||
|
|
||||||
// TODO: check if online
|
|
||||||
NSLog("sending playback report")
|
|
||||||
ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//
|
||||||
|
// PlayerProgressSync.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/19/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class PlayerProgress {
|
||||||
|
public static let shared = PlayerProgress()
|
||||||
|
|
||||||
|
private static let TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 10.0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
|
||||||
|
// 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 func syncToServer() async {
|
||||||
|
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer")
|
||||||
|
do {
|
||||||
|
try await updateAllServerSessionFromLocalSession()
|
||||||
|
} catch {
|
||||||
|
debugPrint("Failed to syncToServer")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func syncFromServer() async {
|
||||||
|
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer")
|
||||||
|
do {
|
||||||
|
try await updateLocalSessionFromServerMediaProgress()
|
||||||
|
} catch {
|
||||||
|
debugPrint("Failed to syncFromServer")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - SYNC LOGIC
|
||||||
|
|
||||||
|
private func updateLocalSessionFromPlayer(currentTime: Double, includesPlayProgress: Bool) throws -> PlaybackSession? {
|
||||||
|
guard let session = PlayerHandler.getPlaybackSession() else { return nil }
|
||||||
|
guard !currentTime.isNaN else { return nil } // Prevent bad data on player stop
|
||||||
|
|
||||||
|
try session.update {
|
||||||
|
session.realm?.refresh()
|
||||||
|
|
||||||
|
let nowInSeconds = Date().timeIntervalSince1970
|
||||||
|
let nowInMilliseconds = nowInSeconds * 1000
|
||||||
|
let lastUpdateInMilliseconds = session.updatedAt ?? nowInMilliseconds
|
||||||
|
let lastUpdateInSeconds = lastUpdateInMilliseconds / 1000
|
||||||
|
let secondsSinceLastUpdate = nowInSeconds - lastUpdateInSeconds
|
||||||
|
|
||||||
|
session.currentTime = currentTime
|
||||||
|
session.updatedAt = nowInMilliseconds
|
||||||
|
|
||||||
|
if includesPlayProgress {
|
||||||
|
session.timeListening += secondsSinceLastUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.freeze()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLocalMediaProgressFromLocalSession() throws {
|
||||||
|
guard let session = PlayerHandler.getPlaybackSession() else { return }
|
||||||
|
guard session.isLocal else { return }
|
||||||
|
|
||||||
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId)
|
||||||
|
guard let localMediaProgress = localMediaProgress else {
|
||||||
|
// Local media progress should have been created
|
||||||
|
// If we're here, it means a library id is invalid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try localMediaProgress.updateFromPlaybackSession(session)
|
||||||
|
|
||||||
|
NSLog("Local progress saved to the database")
|
||||||
|
|
||||||
|
// Send the local progress back to front-end
|
||||||
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAllServerSessionFromLocalSession() async throws {
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { [self] group in
|
||||||
|
for session in try await Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) {
|
||||||
|
let session = session.freeze()
|
||||||
|
group.addTask {
|
||||||
|
try await self.updateServerSessionFromLocalSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await group.waitForAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws {
|
||||||
|
var safeToSync = true
|
||||||
|
|
||||||
|
guard var session = session.thaw() else { return }
|
||||||
|
|
||||||
|
// We need to update and check the server time in a transaction for thread-safety
|
||||||
|
try session.update {
|
||||||
|
session.realm?.refresh()
|
||||||
|
|
||||||
|
let nowInMilliseconds = Date().timeIntervalSince1970 * 1000
|
||||||
|
let lastUpdateInMilliseconds = session.serverUpdatedAt
|
||||||
|
|
||||||
|
// If required, rate limit requests based on session last update
|
||||||
|
if rateLimitSync {
|
||||||
|
let timeSinceLastSync = nowInMilliseconds - lastUpdateInMilliseconds
|
||||||
|
let timeBetweenSessionSync = PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS * 1000
|
||||||
|
safeToSync = timeSinceLastSync > timeBetweenSessionSync
|
||||||
|
if !safeToSync {
|
||||||
|
return // This only exits the update block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.serverUpdatedAt = nowInMilliseconds
|
||||||
|
}
|
||||||
|
session = session.freeze()
|
||||||
|
|
||||||
|
guard safeToSync else { return }
|
||||||
|
NSLog("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
|
||||||
|
|
||||||
|
var success = false
|
||||||
|
if session.isLocal {
|
||||||
|
success = await ApiClient.reportLocalPlaybackProgress(session)
|
||||||
|
} else {
|
||||||
|
let playbackReport = PlaybackReport(currentTime: session.currentTime, duration: session.duration, timeListened: session.timeListening)
|
||||||
|
success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Remove old sessions after they synced with the server
|
||||||
|
if success && !session.isActiveSession {
|
||||||
|
if let session = session.thaw() {
|
||||||
|
try session.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLocalSessionFromServerMediaProgress() async throws {
|
||||||
|
NSLog("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
|
||||||
|
guard let session = try await Realm().objects(PlaybackSession.self).last(where: {
|
||||||
|
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
|
})?.freeze() else {
|
||||||
|
NSLog("updateLocalSessionFromServerMediaProgress: Failed to get session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the current progress
|
||||||
|
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
|
||||||
|
guard let progress = progress else {
|
||||||
|
NSLog("updateLocalSessionFromServerMediaProgress: No progress object")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which session is newer
|
||||||
|
let serverLastUpdate = progress.lastUpdate
|
||||||
|
guard let localLastUpdate = session.updatedAt else {
|
||||||
|
NSLog("updateLocalSessionFromServerMediaProgress: No local session updatedAt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let serverCurrentTime = progress.currentTime
|
||||||
|
let localCurrentTime = session.currentTime
|
||||||
|
|
||||||
|
let serverIsNewerThanLocal = serverLastUpdate > localLastUpdate
|
||||||
|
let currentTimeIsDifferent = serverCurrentTime != localCurrentTime
|
||||||
|
|
||||||
|
// Update the session, if needed
|
||||||
|
if serverIsNewerThanLocal && currentTimeIsDifferent {
|
||||||
|
NSLog("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
|
||||||
|
guard let session = session.thaw() else { return }
|
||||||
|
try session.update {
|
||||||
|
session.currentTime = serverCurrentTime
|
||||||
|
session.updatedAt = serverLastUpdate
|
||||||
|
}
|
||||||
|
NSLog("updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
|
||||||
|
PlayerHandler.seek(amount: session.currentTime)
|
||||||
|
} else {
|
||||||
|
NSLog("updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -37,7 +37,37 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) {
|
|
||||||
|
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
|
||||||
|
if (Store.serverConfig == nil) {
|
||||||
|
NSLog("Server config not set")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: HTTPHeaders = [
|
||||||
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
|
]
|
||||||
|
|
||||||
|
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in
|
||||||
|
switch response.result {
|
||||||
|
case .success(let obj):
|
||||||
|
callback?(obj)
|
||||||
|
case .failure(let error):
|
||||||
|
NSLog("api request to \(endpoint) failed")
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func postResource<T:Encodable>(endpoint: String, parameters: T) async -> Bool {
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
postResource(endpoint: endpoint, parameters: parameters) { success in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
NSLog("Server config not set")
|
NSLog("Server config not set")
|
||||||
callback?(false)
|
callback?(false)
|
||||||
@@ -50,7 +80,7 @@ class ApiClient {
|
|||||||
|
|
||||||
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
|
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
|
||||||
switch response.result {
|
switch response.result {
|
||||||
case .success(let _):
|
case .success(_):
|
||||||
callback?(true)
|
callback?(true)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
NSLog("api request to \(endpoint) failed")
|
NSLog("api request to \(endpoint) failed")
|
||||||
@@ -60,6 +90,38 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
|
if (Store.serverConfig == nil) {
|
||||||
|
NSLog("Server config not set")
|
||||||
|
callback?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: HTTPHeaders = [
|
||||||
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
|
]
|
||||||
|
|
||||||
|
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
|
||||||
|
switch response.result {
|
||||||
|
case .success(_):
|
||||||
|
callback?(true)
|
||||||
|
case .failure(let error):
|
||||||
|
NSLog("api request to \(endpoint) failed")
|
||||||
|
print(error)
|
||||||
|
callback?(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self) async -> T? {
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
getResource(endpoint: endpoint, decodable: decodable) { result in
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
NSLog("Server config not set")
|
NSLog("Server config not set")
|
||||||
@@ -96,7 +158,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient.postResource(endpoint: endpoint, parameters: [
|
let parameters: [String: Any] = [
|
||||||
"forceDirectPlay": !forceTranscode ? "1" : "",
|
"forceDirectPlay": !forceTranscode ? "1" : "",
|
||||||
"forceTranscode": forceTranscode ? "1" : "",
|
"forceTranscode": forceTranscode ? "1" : "",
|
||||||
"mediaPlayer": "AVPlayer",
|
"mediaPlayer": "AVPlayer",
|
||||||
@@ -105,7 +167,8 @@ class ApiClient {
|
|||||||
"model": modelCode,
|
"model": modelCode,
|
||||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
]
|
]
|
||||||
], decodable: PlaybackSession.self) { obj in
|
]
|
||||||
|
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
|
||||||
var session = obj
|
var session = obj
|
||||||
|
|
||||||
session.serverConnectionConfigId = Store.serverConfig!.id
|
session.serverConnectionConfigId = Store.serverConfig!.id
|
||||||
@@ -114,9 +177,60 @@ class ApiClient {
|
|||||||
callback(session)
|
callback(session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) {
|
|
||||||
try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil)
|
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
|
||||||
|
return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
||||||
|
return await postResource(endpoint: "api/session/local", parameters: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) {
|
||||||
|
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||||
|
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
|
}.map { $0.freeze() }
|
||||||
|
|
||||||
|
if ( !localMediaProgressList.isEmpty ) {
|
||||||
|
let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList)
|
||||||
|
NSLog("Sending sync local progress request with \(localMediaProgressList.count) progress items")
|
||||||
|
postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in
|
||||||
|
let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count)
|
||||||
|
NSLog("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))")
|
||||||
|
|
||||||
|
if let updates = response.localProgressUpdates {
|
||||||
|
for update in updates {
|
||||||
|
do {
|
||||||
|
try update.save()
|
||||||
|
} catch {
|
||||||
|
debugPrint("Failed to update local media progress")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(resultsPayload)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NSLog("No local media progress to sync")
|
||||||
|
callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
|
||||||
|
NSLog("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
|
||||||
|
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||||
|
patchResource(endpoint: endpoint, parameters: payload) { success in
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
|
||||||
|
NSLog("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
|
||||||
|
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||||
|
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
|
||||||
|
}
|
||||||
|
|
||||||
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
||||||
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
||||||
if episodeId != nil {
|
if episodeId != nil {
|
||||||
@@ -128,3 +242,35 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LocalMediaProgressSyncPayload: Codable {
|
||||||
|
var localMediaProgress: [LocalMediaProgress]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MediaProgressSyncResponsePayload: Decodable {
|
||||||
|
var numServerProgressUpdates: Int?
|
||||||
|
var localProgressUpdates: [LocalMediaProgress]?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case numServerProgressUpdates, localProgressUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
numServerProgressUpdates = try? values.intOrStringDecoder(key: .numServerProgressUpdates)
|
||||||
|
localProgressUpdates = try? values.decode([LocalMediaProgress].self, forKey: .localProgressUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalMediaProgressSyncResultsPayload: Codable {
|
||||||
|
var numLocalMediaProgressForServer: Int?
|
||||||
|
var numServerProgressUpdates: Int?
|
||||||
|
var numLocalProgressUpdates: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Connectivity {
|
||||||
|
static private let sharedInstance = NetworkReachabilityManager()!
|
||||||
|
static var isConnectedToInternet:Bool {
|
||||||
|
return self.sharedInstance.isReachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// DaoExtensions.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
extension Object {
|
||||||
|
func save() throws {
|
||||||
|
let realm = try Realm()
|
||||||
|
try realm.write {
|
||||||
|
realm.add(self, update: .modified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(handler: () -> Void) throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EmbeddedObject {
|
||||||
|
// Required to disassociate from Realm when copying into local objects
|
||||||
|
static func detachCopy<T:Codable>(of object: T?) -> T? {
|
||||||
|
guard let object = object else { return nil }
|
||||||
|
let json = try! JSONEncoder().encode(object)
|
||||||
|
return try! JSONDecoder().decode(T.self, from: json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol Deletable {
|
||||||
|
func delete() throws
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Deletable where Self: Object {
|
||||||
|
func delete() throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.realm?.delete(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,30 +9,35 @@ import Foundation
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class Database {
|
class Database {
|
||||||
// All DB releated actions must be executed on "realm-queue"
|
|
||||||
public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue")
|
|
||||||
public static var shared = {
|
public static var shared = {
|
||||||
realmQueue.sync {
|
return Database()
|
||||||
return Database()
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private var instance: Realm
|
private init() {}
|
||||||
private init() {
|
|
||||||
self.instance = try! Realm(queue: Database.realmQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setServerConnectionConfig(config: ServerConnectionConfig) {
|
public func setServerConnectionConfig(config: ServerConnectionConfig) {
|
||||||
var refrence: ThreadSafeReference<ServerConnectionConfig>?
|
let config = config
|
||||||
if config.realm != nil {
|
let realm = try! Realm()
|
||||||
refrence = ThreadSafeReference(to: config)
|
let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
|
||||||
}
|
|
||||||
|
|
||||||
Database.realmQueue.sync {
|
if let existing = existing {
|
||||||
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
|
do {
|
||||||
|
try existing.update {
|
||||||
|
existing.name = config.name
|
||||||
|
existing.address = config.address
|
||||||
|
existing.userId = config.userId
|
||||||
|
existing.username = config.username
|
||||||
|
existing.token = config.token
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("failed to update server config")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastActiveConfigIndex(index: existing.index)
|
||||||
|
} else {
|
||||||
if config.index == 0 {
|
if config.index == 0 {
|
||||||
let lastConfig: ServerConnectionConfig? = instance.objects(ServerConnectionConfig.self).last
|
let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last
|
||||||
|
|
||||||
if lastConfig != nil {
|
if lastConfig != nil {
|
||||||
config.index = lastConfig!.index + 1
|
config.index = lastConfig!.index + 1
|
||||||
@@ -42,19 +47,8 @@ class Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try instance.write {
|
try realm.write {
|
||||||
if existing != nil {
|
realm.add(config)
|
||||||
instance.delete(existing!)
|
|
||||||
}
|
|
||||||
if refrence == nil {
|
|
||||||
instance.add(config)
|
|
||||||
} else {
|
|
||||||
guard let resolved = instance.resolve(refrence!) else {
|
|
||||||
throw "unable to resolve refrence"
|
|
||||||
}
|
|
||||||
|
|
||||||
instance.add(resolved);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to save server config")
|
NSLog("failed to save server config")
|
||||||
@@ -64,88 +58,194 @@ class Database {
|
|||||||
setLastActiveConfigIndex(index: config.index)
|
setLastActiveConfigIndex(index: config.index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deleteServerConnectionConfig(id: String) {
|
public func deleteServerConnectionConfig(id: String) {
|
||||||
Database.realmQueue.sync {
|
let realm = try! Realm()
|
||||||
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
|
let config = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
|
||||||
|
|
||||||
do {
|
|
||||||
try instance.write {
|
|
||||||
if config != nil {
|
|
||||||
instance.delete(config!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(let exception) {
|
|
||||||
NSLog("failed to delete server config")
|
|
||||||
debugPrint(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
|
|
||||||
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
|
|
||||||
|
|
||||||
Database.realmQueue.sync {
|
|
||||||
let configs = instance.objects(ServerConnectionConfig.self)
|
|
||||||
refrences = configs.map { config in
|
|
||||||
return ThreadSafeReference(to: config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let realm = try Realm()
|
try realm.write {
|
||||||
|
if config != nil {
|
||||||
return refrences.map { refrence in
|
realm.delete(config!)
|
||||||
return realm.resolve(refrence)!
|
}
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("error while readling configs")
|
NSLog("failed to delete server config")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setLastActiveConfigIndexToNil() {
|
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
|
||||||
Database.realmQueue.sync {
|
let realm = try! Realm()
|
||||||
setLastActiveConfigIndex(index: nil)
|
return Array(realm.objects(ServerConnectionConfig.self))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
public func setLastActiveConfigIndex(index: Int?) {
|
|
||||||
let existing = instance.objects(ServerConnectionConfigActiveIndex.self)
|
public func setLastActiveConfigIndexToNil() {
|
||||||
let obj = ServerConnectionConfigActiveIndex()
|
setLastActiveConfigIndex(index: nil)
|
||||||
obj.index = index
|
}
|
||||||
|
|
||||||
|
private func setLastActiveConfigIndex(index: Int?) {
|
||||||
|
let realm = try! Realm()
|
||||||
do {
|
do {
|
||||||
try instance.write {
|
try realm.write {
|
||||||
instance.delete(existing)
|
let existing = realm.objects(ServerConnectionConfigActiveIndex.self).last
|
||||||
instance.add(obj)
|
|
||||||
|
if ( existing?.index != index ) {
|
||||||
|
if let existing = existing {
|
||||||
|
realm.delete(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeConfig = ServerConnectionConfigActiveIndex()
|
||||||
|
activeConfig.index = index
|
||||||
|
realm.add(activeConfig)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to save server config active index")
|
NSLog("failed to save server config active index")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getLastActiveConfigIndex() -> Int? {
|
public func getLastActiveConfigIndex() -> Int? {
|
||||||
return Database.realmQueue.sync {
|
let realm = try! Realm()
|
||||||
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
return realm.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setDeviceSettings(deviceSettings: DeviceSettings) {
|
public func setDeviceSettings(deviceSettings: DeviceSettings) {
|
||||||
Database.realmQueue.sync {
|
let realm = try! Realm()
|
||||||
let existing = instance.objects(DeviceSettings.self)
|
let existing = realm.objects(DeviceSettings.self)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try instance.write {
|
try realm.write {
|
||||||
instance.delete(existing)
|
realm.delete(existing)
|
||||||
instance.add(deviceSettings)
|
realm.add(deviceSettings)
|
||||||
}
|
|
||||||
} catch(let exception) {
|
|
||||||
NSLog("failed to save device settings")
|
|
||||||
debugPrint(exception)
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("failed to save device settings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return Array(realm.objects(LocalLibraryItem.self))
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId })
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId)
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) throws {
|
||||||
|
let realm = try Realm()
|
||||||
|
try realm.write { realm.add(localLibraryItem, update: .modified) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getLocalFile(localFileId: String) -> LocalFile? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId)
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId)
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getDownloadItem(libraryItemId: String) -> DownloadItem? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveDownloadItem(_ downloadItem: DownloadItem) throws {
|
||||||
|
let realm = try Realm()
|
||||||
|
return try realm.write { realm.add(downloadItem, update: .modified) }
|
||||||
|
}
|
||||||
|
|
||||||
public func getDeviceSettings() -> DeviceSettings {
|
public func getDeviceSettings() -> DeviceSettings {
|
||||||
return Database.realmQueue.sync {
|
let realm = try! Realm()
|
||||||
return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
|
return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return Array(realm.objects(LocalMediaProgress.self))
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||||
|
public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeLocalMediaProgress(localMediaProgressId: String) throws {
|
||||||
|
let realm = try Realm()
|
||||||
|
try realm.write {
|
||||||
|
let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
|
||||||
|
realm.delete(progress!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getPlaybackSession(id: String) -> PlaybackSession? {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id)
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,66 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
import Capacitor
|
||||||
|
import CoreMedia
|
||||||
|
|
||||||
extension String: Error {}
|
extension String: Error {}
|
||||||
|
|
||||||
|
typealias Dictionaryable = Encodable
|
||||||
|
|
||||||
extension Encodable {
|
extension Encodable {
|
||||||
func asDictionary() throws -> [String: Any] {
|
func asDictionary() throws -> [String: Any] {
|
||||||
let data = try JSONEncoder().encode(self)
|
let data = try JSONEncoder().encode(self)
|
||||||
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
|
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
|
||||||
throw NSError()
|
throw NSError()
|
||||||
|
}
|
||||||
|
return dictionary
|
||||||
}
|
}
|
||||||
return dictionary
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Collection where Iterator.Element: Encodable {
|
||||||
|
func asDictionaryArray() throws -> [[String: Any]] {
|
||||||
|
return try self.enumerated().map() {
|
||||||
|
i, element -> [String: Any] in try element.asDictionary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension KeyedDecodingContainer {
|
||||||
|
func doubleOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Double {
|
||||||
|
do {
|
||||||
|
return try decode(Double.self, forKey: key)
|
||||||
|
} catch {
|
||||||
|
let stringValue = try decode(String.self, forKey: key)
|
||||||
|
return Double(stringValue) ?? 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Int {
|
||||||
|
do {
|
||||||
|
return try decode(Int.self, forKey: key)
|
||||||
|
} catch {
|
||||||
|
let stringValue = try decode(String.self, forKey: key)
|
||||||
|
return Int(stringValue) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CAPPluginCall {
|
||||||
|
func getJson<T: Decodable>(_ key: String, type: T.Type) -> T? {
|
||||||
|
guard let value = getObject(key) else { return nil }
|
||||||
|
do {
|
||||||
|
let json = try JSONSerialization.data(withJSONObject: value)
|
||||||
|
return try JSONDecoder().decode(type, from: json)
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to get json for \(key)")
|
||||||
|
debugPrint(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension DispatchQueue {
|
extension DispatchQueue {
|
||||||
static func runOnMainQueue(callback: @escaping (() -> Void)) {
|
static func runOnMainQueue(callback: @escaping (() -> Void)) {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
@@ -29,3 +77,26 @@ extension DispatchQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
var attributes: [FileAttributeKey : Any]? {
|
||||||
|
do {
|
||||||
|
return try FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
} catch let error as NSError {
|
||||||
|
print("FileAttribute error: \(error)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileSize: Int64 {
|
||||||
|
return attributes?[.size] as? Int64 ?? Int64(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileSizeString: String {
|
||||||
|
return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
var creationDate: Date? {
|
||||||
|
return attributes?[.creationDate] as? Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ struct NowPlayingMetadata {
|
|||||||
var title: String
|
var title: String
|
||||||
var author: String?
|
var author: String?
|
||||||
var series: String?
|
var series: String?
|
||||||
|
var coverUrl: URL? {
|
||||||
|
guard let config = Store.serverConfig else { return nil }
|
||||||
|
guard let url = URL(string: "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)") else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class NowPlayingInfo {
|
class NowPlayingInfo {
|
||||||
@@ -30,28 +35,43 @@ class NowPlayingInfo {
|
|||||||
public func setSessionMetadata(metadata: NowPlayingMetadata) {
|
public func setSessionMetadata(metadata: NowPlayingMetadata) {
|
||||||
setMetadata(artwork: nil, metadata: metadata)
|
setMetadata(artwork: nil, metadata: metadata)
|
||||||
|
|
||||||
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
|
let isLocalItem = metadata.itemId.starts(with: "local_")
|
||||||
return
|
if isLocalItem {
|
||||||
}
|
guard let artworkUrl = metadata.artworkUrl else { return }
|
||||||
ApiClient.getData(from: url) { [self] image in
|
let coverImage = UIImage(contentsOfFile: artworkUrl)
|
||||||
guard let downloadedImage = image else {
|
guard let coverImage = coverImage else { return }
|
||||||
return
|
let artwork = MPMediaItemArtwork(boundsSize: coverImage.size) { _ -> UIImage in
|
||||||
|
return coverImage
|
||||||
}
|
}
|
||||||
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
|
|
||||||
return downloadedImage
|
|
||||||
})
|
|
||||||
|
|
||||||
self.setMetadata(artwork: artwork, metadata: metadata)
|
self.setMetadata(artwork: artwork, metadata: metadata)
|
||||||
|
} else {
|
||||||
|
guard let url = metadata.coverUrl else { return }
|
||||||
|
ApiClient.getData(from: url) { [self] image in
|
||||||
|
guard let downloadedImage = image else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
|
||||||
|
return downloadedImage
|
||||||
|
})
|
||||||
|
|
||||||
|
self.setMetadata(artwork: artwork, metadata: metadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public func update(duration: Double, currentTime: Double, rate: Float) {
|
public func update(duration: Double, currentTime: Double, rate: Float) {
|
||||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
// Update on the main to prevent access collisions
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
DispatchQueue.main.async { [weak self] in
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
if let self = self {
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
|
self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
||||||
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
||||||
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reset() {
|
public func reset() {
|
||||||
nowPlayingInfo = [:]
|
nowPlayingInfo = [:]
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
@@ -76,6 +96,7 @@ class NowPlayingInfo {
|
|||||||
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
||||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
|
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldFetchCover(id: String) -> Bool {
|
private func shouldFetchCover(id: String) -> Bool {
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
|
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ enum PlayerEvents: String {
|
|||||||
case sleepSet = "com.audiobookshelf.app.player.sleep.set"
|
case sleepSet = "com.audiobookshelf.app.player.sleep.set"
|
||||||
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
|
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
|
||||||
case failed = "com.audiobookshelf.app.player.failed"
|
case failed = "com.audiobookshelf.app.player.failed"
|
||||||
|
case localProgress = "com.audiobookshelf.app.player.localProgress"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ import Foundation
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
@ThreadSafe 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,10 +27,6 @@ class Store {
|
|||||||
} else {
|
} else {
|
||||||
Database.shared.setLastActiveConfigIndexToNil()
|
Database.shared.setLastActiveConfigIndexToNil()
|
||||||
}
|
}
|
||||||
|
|
||||||
Database.realmQueue.sync {
|
|
||||||
_serverConfig = updated
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,9 @@ export default {
|
|||||||
|
|
||||||
this.loadSavedSettings()
|
this.loadSavedSettings()
|
||||||
this.hasMounted = true
|
this.hasMounted = true
|
||||||
|
|
||||||
|
console.log('[default] fully initialized')
|
||||||
|
this.$eventBus.$emit('abs-ui-ready')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default {
|
|||||||
})
|
})
|
||||||
categories = categories.map((cat) => {
|
categories = categories.map((cat) => {
|
||||||
console.log('[breadcrumb] Personalized category from server', cat.type)
|
console.log('[breadcrumb] Personalized category from server', cat.type)
|
||||||
if (cat.type == 'book' || cat.type == 'podcast') {
|
if (cat.type == 'book' || cat.type == 'podcast' || cat.type == 'episode') {
|
||||||
// Map localLibraryItem to entities
|
// Map localLibraryItem to entities
|
||||||
cat.entities = cat.entities.map((entity) => {
|
cat.entities = cat.entities.map((entity) => {
|
||||||
var localLibraryItem = this.localLibraryItems.find((lli) => {
|
var localLibraryItem = this.localLibraryItems.find((lli) => {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full py-6 px-4 overflow-y-auto">
|
||||||
|
<p class="mb-2 text-base text-white">Downloads ({{ localLibraryItems.length }})</p>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<template v-for="(mediaItem, num) in localLibraryItems">
|
||||||
|
<div :key="mediaItem.id" class="w-full">
|
||||||
|
<nuxt-link :to="`/localMedia/item/${mediaItem.id}`" class="flex items-center">
|
||||||
|
<div class="w-16 h-16 min-w-16 min-h-16 flex-none bg-primary relative">
|
||||||
|
<img v-if="mediaItem.coverPathSrc" :src="mediaItem.coverPathSrc" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="px-2 flex-grow">
|
||||||
|
<p class="text-sm">{{ mediaItem.media.metadata.title }}</p>
|
||||||
|
<p v-if="mediaItem.mediaType == 'book'" class="text-xs text-gray-300">{{ mediaItem.media.tracks.length }} Track{{ mediaItem.media.tracks.length == 1 ? '' : 's' }}</p>
|
||||||
|
<p v-else-if="mediaItem.mediaType == 'podcast'" class="text-xs text-gray-300">{{ mediaItem.media.episodes.length }} Episode{{ mediaItem.media.episodes.length == 1 ? '' : 's' }}</p>
|
||||||
|
<p v-if="mediaItem.size" class="text-xs text-gray-300">{{ $bytesPretty(mediaItem.size) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl text-gray-400">chevron_right</span>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
<div v-if="num+1 < localLibraryItems.length" class="flex border-t border-white border-opacity-10 my-3" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
localLibraryItems: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getSize(item) {
|
||||||
|
if (!item || !item.localFiles) return 0
|
||||||
|
let size = 0
|
||||||
|
for (let i = 0; i < item.localFiles.length; i++) {
|
||||||
|
size += item.localFiles[i].size
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
},
|
||||||
|
newLocalLibraryItem(item) {
|
||||||
|
if (!item) return
|
||||||
|
const itemIndex = this.localLibraryItems.findIndex((li) => li.id === item.id)
|
||||||
|
const newItemObj = {
|
||||||
|
...item,
|
||||||
|
size: this.getSize(item),
|
||||||
|
coverPathSrc: item.coverContentUrl ? Capacitor.convertFileSrc(item.coverContentUrl) : null
|
||||||
|
}
|
||||||
|
if (itemIndex >= 0) {
|
||||||
|
this.localLibraryItems.splice(itemIndex, 1, newItemObj)
|
||||||
|
} else {
|
||||||
|
this.localLibraryItems.push(newItemObj)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
var items = (await this.$db.getLocalLibraryItems()) || []
|
||||||
|
this.localLibraryItems = items.map((lmi) => {
|
||||||
|
console.log('Local library item', JSON.stringify(lmi))
|
||||||
|
return {
|
||||||
|
...lmi,
|
||||||
|
size: this.getSize(lmi),
|
||||||
|
coverPathSrc: lmi.coverContentUrl ? Capacitor.convertFileSrc(lmi.coverContentUrl) : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
+7
-1
@@ -311,7 +311,6 @@ export default {
|
|||||||
return this.ebookFile && this.ebookFormat !== 'pdf'
|
return this.ebookFile && this.ebookFormat !== 'pdf'
|
||||||
},
|
},
|
||||||
showDownload() {
|
showDownload() {
|
||||||
if (this.isIos) return false
|
|
||||||
if (this.isPodcast) return false
|
if (this.isPodcast) return false
|
||||||
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
|
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
|
||||||
},
|
},
|
||||||
@@ -584,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<span class="material-icons" @click="showItemDialog">more_vert</span>
|
<span class="material-icons" @click="showItemDialog">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
|
<p v-if="!isIos" class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
|
||||||
|
|
||||||
<p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p>
|
<p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p>
|
||||||
|
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
<div v-if="!isPodcast" class="w-full">
|
<div v-if="!isPodcast" class="w-full">
|
||||||
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
|
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
|
||||||
|
|
||||||
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos">
|
||||||
<transition-group type="transition" :name="!drag ? 'dragtrack' : null">
|
<transition-group type="transition" :name="!drag ? 'dragtrack' : null">
|
||||||
<template v-for="track in audioTracksCopy">
|
<template v-for="track in audioTracksCopy">
|
||||||
<div :key="track.localFileId" class="flex items-center my-1 item">
|
<div :key="track.localFileId" class="flex items-center my-1 item">
|
||||||
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
|
<div v-if="!isIos" class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
|
||||||
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
|
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<p class="text-xs">{{ track.mimeType }}</p>
|
<p class="text-xs">{{ track.mimeType }}</p>
|
||||||
<p class="text-sm">{{ $elapsedPretty(track.duration) }}</p>
|
<p class="text-sm">{{ $elapsedPretty(track.duration) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
|
<div v-if="!isIos" class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
|
||||||
<span class="material-icons" @click="showTrackDialog(track)">more_vert</span>
|
<span class="material-icons" @click="showTrackDialog(track)">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +138,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isIos() {
|
||||||
|
return this.$platform === 'ios'
|
||||||
|
},
|
||||||
basePath() {
|
basePath() {
|
||||||
return this.localLibraryItem ? this.localLibraryItem.basePath : null
|
return this.localLibraryItem ? this.localLibraryItem.basePath : null
|
||||||
},
|
},
|
||||||
@@ -194,24 +197,14 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
return [
|
var options = []
|
||||||
{
|
if ( !this.isIos ) {
|
||||||
text: 'Scan',
|
options.push({ text: 'Scan', value: 'scan'})
|
||||||
value: 'scan'
|
options.push({ text: 'Force Re-Scan', value: 'rescan'})
|
||||||
},
|
options.push({ text: 'Remove', value: 'remove'})
|
||||||
{
|
}
|
||||||
text: 'Force Re-Scan',
|
options.push({ text: 'Remove & Delete Files', value: 'delete'})
|
||||||
value: 'rescan'
|
return options
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Remove',
|
|
||||||
value: 'remove'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Remove & Delete Files',
|
|
||||||
value: 'delete'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -329,13 +322,13 @@ export default {
|
|||||||
async deleteItem() {
|
async deleteItem() {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: 'Confirm',
|
||||||
message: `Warning! This will delete the folder "${this.basePath}" and all contents. Are you sure?`
|
message: `Warning! This will delete "${this.media.metadata.title}" and all associated local files. Are you sure?`
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||||
if (res && res.success) {
|
if (res && res.success) {
|
||||||
this.$toast.success('Deleted Successfully')
|
this.$toast.success('Deleted Successfully')
|
||||||
this.$router.replace(`/localMedia/folders/${this.folderId}`)
|
this.$router.replace(this.isIos ? '/bookshelf' : `/localMedia/folders/${this.folderId}`)
|
||||||
} else this.$toast.error('Failed to delete')
|
} else this.$toast.error('Failed to delete')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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