Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bbcd300e4 | ||
|
|
c54d9b3c5f | ||
|
|
9ab03fbcd4 | ||
|
|
a852114e9c | ||
|
|
3c9b8c1c6f | ||
|
|
4735b51dc0 | ||
|
|
23927a1d59 | ||
|
|
40b592f7c1 | ||
|
|
4da31f5850 | ||
|
|
f79e96cbfe | ||
|
|
2dbce1e3bb | ||
|
|
cdfbbcbf8a | ||
|
|
890852bef5 | ||
|
|
3b285db21a | ||
|
|
d37be88977 | ||
|
|
e6d27a1487 | ||
|
|
5d23c17d30 | ||
|
|
9c9c1c3b94 | ||
|
|
4e94fd6ad0 | ||
|
|
81a8203cca | ||
|
|
b6b206c288 | ||
|
|
ed2310a2e7 | ||
|
|
bae5d97087 | ||
|
|
3e12ff139b | ||
|
|
7ccd8db085 | ||
|
|
fdc829d06c | ||
|
|
e7f61e34e8 | ||
|
|
141877f111 | ||
|
|
b71a976e61 | ||
|
|
68c95ed6d5 | ||
|
|
7a68cc99af | ||
|
|
7294bc7033 | ||
|
|
f8836be147 | ||
|
|
713999eb83 | ||
|
|
cc4c9787c0 | ||
|
|
ecc5cf3e1b | ||
|
|
ba7d2be03e | ||
|
|
368f349c78 | ||
|
|
639d641c07 | ||
|
|
d9313a1654 | ||
|
|
9dd532285c | ||
|
|
92f10fdb5c | ||
|
|
9d0f43a664 | ||
|
|
453365fcd6 | ||
|
|
6231c5fcc4 | ||
|
|
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"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 87
|
||||
versionName "0.9.56-beta"
|
||||
versionCode 89
|
||||
versionName "0.9.59-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -43,6 +43,12 @@ android {
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
versionNameSuffix "-debug"
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -1,69 +1,11 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.fasterxml.jackson.annotation.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LibraryItem(
|
||||
var id:String,
|
||||
var ino:String,
|
||||
var libraryId:String,
|
||||
var folderId:String,
|
||||
var path:String,
|
||||
var relPath:String,
|
||||
var mtimeMs:Long,
|
||||
var ctimeMs:Long,
|
||||
var birthtimeMs:Long,
|
||||
var addedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var lastScan:Long?,
|
||||
var scanVersion:String?,
|
||||
var isMissing:Boolean,
|
||||
var isInvalid:Boolean,
|
||||
var mediaType:String,
|
||||
var media:MediaType,
|
||||
var libraryFiles:MutableList<LibraryFile>?,
|
||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||
) : LibraryItemWrapper() {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@get:JsonIgnore
|
||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri():Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun checkHasTracks():Boolean {
|
||||
return if (mediaType == "podcast") {
|
||||
((media as Podcast).numEpisodes ?: 0) > 0
|
||||
} else {
|
||||
((media as Book).numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
// This auto-detects whether it is a Book or Podcast
|
||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(
|
||||
@@ -121,7 +63,7 @@ class Podcast(
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
||||
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -142,7 +84,7 @@ class Podcast(
|
||||
}
|
||||
@JsonIgnore
|
||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
||||
val newEpisode = PodcastEpisode("local_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -293,24 +235,51 @@ data class PodcastEpisode(
|
||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat {
|
||||
var coverUri:Uri = Uri.EMPTY
|
||||
val podcast = if(libraryItem is LocalLibraryItem) {
|
||||
coverUri = libraryItem.getCoverUri()
|
||||
libraryItem.media as Podcast
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
val coverUri = if(libraryItem is LocalLibraryItem) {
|
||||
libraryItem.getCoverUri()
|
||||
} else {
|
||||
coverUri = (libraryItem as LibraryItem).getCoverUri()
|
||||
(libraryItem as LibraryItem).media as Podcast
|
||||
(libraryItem as LibraryItem).getCoverUri()
|
||||
}
|
||||
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
|
||||
}.build()
|
||||
val extras = Bundle()
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
// return MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
//
|
||||
// }.build()
|
||||
val libraryItemDescription = libraryItem.getMediaDescription(null)
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(libraryItemDescription.title)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
@@ -37,9 +38,9 @@ data class DeviceSettings(
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
@@ -92,7 +93,10 @@ data class LocalFolder(
|
||||
JsonSubTypes.Type(LibraryItem::class),
|
||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||
)
|
||||
open class LibraryItemWrapper()
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Used in Android Auto to represent a podcast episode or an audiobook in progress
|
||||
*/
|
||||
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.json.JSONObject
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ItemInProgress(
|
||||
val libraryItemWrapper: LibraryItemWrapper,
|
||||
val episode: PodcastEpisode?,
|
||||
val progressLastUpdate: Long,
|
||||
val isLocal: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||
|
||||
var episode:PodcastEpisode? = null
|
||||
if (serverItem.has("recentEpisode")) {
|
||||
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
|
||||
}
|
||||
|
||||
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
|
||||
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class LibraryItem(
|
||||
id:String,
|
||||
var ino:String,
|
||||
var libraryId:String,
|
||||
var folderId:String,
|
||||
var path:String,
|
||||
var relPath:String,
|
||||
var mtimeMs:Long,
|
||||
var ctimeMs:Long,
|
||||
var birthtimeMs:Long,
|
||||
var addedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var lastScan:Long?,
|
||||
var scanVersion:String?,
|
||||
var isMissing:Boolean,
|
||||
var isInvalid:Boolean,
|
||||
var mediaType:String,
|
||||
var media:MediaType,
|
||||
var libraryFiles:MutableList<LibraryFile>?,
|
||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@get:JsonIgnore
|
||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun checkHasTracks():Boolean {
|
||||
return if (mediaType == "podcast") {
|
||||
((media as Podcast).numEpisodes ?: 0) > 0
|
||||
} else {
|
||||
((media as Book).numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(getCoverUri())
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.R
|
||||
@@ -14,8 +12,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import java.util.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalLibraryItem(
|
||||
var id:String,
|
||||
class LocalLibraryItem(
|
||||
id:String,
|
||||
var folderId:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
@@ -32,7 +30,7 @@ data class LocalLibraryItem(
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var libraryItemId:String?
|
||||
) : LibraryItemWrapper() {
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@get:JsonIgnore
|
||||
@@ -98,16 +96,38 @@ data class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(): MediaMetadataCompat {
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri()
|
||||
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
||||
}.build()
|
||||
val extras = Bundle()
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@ package com.audiobookshelf.app.media
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
@@ -26,11 +22,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||
var selectedLibraryId = ""
|
||||
|
||||
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
|
||||
var selectedPodcast:Podcast? = null
|
||||
var selectedLibraryItemId:String? = null
|
||||
var serverPodcastEpisodes = listOf<PodcastEpisode>()
|
||||
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
var serverConfigIdUsed:String? = null
|
||||
var serverConfigLastPing:Long = 0L
|
||||
@@ -71,7 +67,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
serverPodcastEpisodes = listOf()
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
@@ -80,13 +76,13 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
||||
if (serverLibraryCategories.isNotEmpty()) {
|
||||
cb(serverLibraryCategories)
|
||||
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||
if (serverItemsInProgress.isNotEmpty()) {
|
||||
cb(serverItemsInProgress)
|
||||
} else {
|
||||
apiHandler.getLibraryCategories(libraryId) {
|
||||
serverLibraryCategories = it
|
||||
cb(it)
|
||||
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||
serverItemsInProgress = itemsInProgress
|
||||
cb(serverItemsInProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,44 +125,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||
|
||||
selectedLibraryItemWrapper = libraryItemWrapper
|
||||
|
||||
libraryItemWrapper?.let {
|
||||
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||
serverPodcastEpisodes = listOf()
|
||||
cb(mutableListOf())
|
||||
} else {
|
||||
val podcast = libraryItemWrapper.media as Podcast
|
||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
||||
selectedLibraryItemId = libraryItemWrapper.id
|
||||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||
|
||||
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
}
|
||||
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||
serverPodcastEpisodes = listOf()
|
||||
cb(mutableListOf())
|
||||
} else {
|
||||
val podcast = libraryItemWrapper.media as Podcast
|
||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
||||
podcast.episodes?.forEach { podcastEpisode ->
|
||||
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
|
||||
}
|
||||
selectedLibraryItemId = libraryItemWrapper.id
|
||||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
|
||||
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
|
||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
@@ -275,26 +266,8 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
|
||||
}
|
||||
|
||||
// TODO: Load currently listening category for local items
|
||||
fun loadLocalCategory():List<LibraryCategory> {
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||
val cats = mutableListOf<LibraryCategory>()
|
||||
if (localBooks.isNotEmpty()) {
|
||||
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
|
||||
}
|
||||
if (localPodcasts.isNotEmpty()) {
|
||||
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
|
||||
}
|
||||
return cats
|
||||
}
|
||||
|
||||
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
val cats = mutableListOf<LibraryCategory>()
|
||||
|
||||
val localCategories = loadLocalCategory()
|
||||
cats.addAll(localCategories)
|
||||
|
||||
// Check if any valid server connection if not use locally downloaded books
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
@@ -304,40 +277,29 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
loadLibraries { libraries ->
|
||||
if (libraries.isEmpty()) {
|
||||
Log.w(tag, "No libraries returned from server request")
|
||||
cb(cats) // Return download category only
|
||||
cb()
|
||||
} else {
|
||||
val library = libraries[0]
|
||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
||||
|
||||
loadLibraryCategories(library.id) { libraryCategories ->
|
||||
|
||||
// Only using book or podcast library categories for now
|
||||
libraryCategories.forEach {
|
||||
|
||||
// Add items in continue listening to serverLibraryItems
|
||||
if (it.id == "continue-listening") {
|
||||
it.entities.forEach { libraryItemWrapper ->
|
||||
val libraryItem = libraryItemWrapper as LibraryItem
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
||||
itemsInProgress.forEach {
|
||||
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
|
||||
|
||||
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
|
||||
if (it.type == library.mediaType) {
|
||||
// Log.d(tag, "Using library category ${it.id}")
|
||||
cats.add(it)
|
||||
if (it.episode != null) {
|
||||
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
||||
}
|
||||
}
|
||||
|
||||
cb(cats)
|
||||
cb() // Fully loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
} else { // Not connected/no internet sent downloaded cats only
|
||||
cb(cats)
|
||||
} else { // Not connected to server
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,12 +317,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
if (id.startsWith("local")) {
|
||||
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||
} else {
|
||||
val podcastEpisode = serverPodcastEpisodes.find { it.id == id }
|
||||
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
|
||||
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return podcastEpisodeLibraryItemMap[id]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,41 +351,6 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaDescriptionFromMediaMetadata(item: MediaMetadataCompat, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
|
||||
val extras = Bundle()
|
||||
if (progress != null) {
|
||||
Log.d(tag, "Has media progress for ${item.description.title} | ${progress}")
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "No media progress for ${item.description.title} | ${item.description.mediaId}")
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(item.description.mediaId)
|
||||
.setTitle(item.description.title)
|
||||
.setIconUri(item.description.iconUri)
|
||||
.setSubtitle(item.description.subtitle)
|
||||
.setExtras(extras).build()
|
||||
}
|
||||
|
||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
||||
val lhsLength = lhs.length + 1
|
||||
val rhsLength = rhs.length + 1
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.audiobookshelf.app.data.*
|
||||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
libraryCategories: List<LibraryCategory>,
|
||||
itemsInProgress: List<ItemInProgress>,
|
||||
libraries: List<Library>
|
||||
) {
|
||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||
@@ -21,8 +21,7 @@ class BrowseTree(
|
||||
* @param drawableId - drawable res id
|
||||
* @return - uri
|
||||
*/
|
||||
fun getUriToDrawable(context: Context,
|
||||
@AnyRes drawableId: Int): Uri {
|
||||
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||
@@ -32,38 +31,26 @@ class BrowseTree(
|
||||
init {
|
||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||
|
||||
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
|
||||
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
|
||||
}.build()
|
||||
|
||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
|
||||
}.build()
|
||||
|
||||
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
||||
}.build()
|
||||
|
||||
// Server continue Listening cat
|
||||
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
|
||||
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
|
||||
val libraryItem = liw as LibraryItem
|
||||
libraryItem.getMediaMetadata()
|
||||
}
|
||||
if (continueListeningMediaMetadata.isNotEmpty()) {
|
||||
rootList += continueReadingMetadata
|
||||
}
|
||||
continueListeningMediaMetadata.forEach {
|
||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
||||
children += it
|
||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
||||
}
|
||||
if (!itemsInProgress.isEmpty()) {
|
||||
rootList += continueListeningMetadata
|
||||
}
|
||||
|
||||
if (libraries.isNotEmpty()) {
|
||||
@@ -78,23 +65,6 @@ class BrowseTree(
|
||||
}
|
||||
|
||||
rootList += downloadsMetadata
|
||||
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
|
||||
localBooksCat.entities.forEach { libc ->
|
||||
val libraryItem = libc as LocalLibraryItem
|
||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
children += libraryItem.getMediaMetadata()
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
||||
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
|
||||
localPodcastsCat.entities.forEach { libc ->
|
||||
val libraryItem = libc as LocalLibraryItem
|
||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
children += libraryItem.getMediaMetadata()
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import java.util.*
|
||||
@@ -21,7 +19,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
private var mediaButtonClickCount: Int = 0
|
||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
||||
var seekAmount: Long = 20000 //ms
|
||||
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||
@@ -75,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
playerNotificationService.seekBackward(seekAmount)
|
||||
playerNotificationService.skipToPrevious()
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.skipToNext()
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
@@ -179,10 +176,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.seekBackward(seekAmount)
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
@@ -226,22 +223,24 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
override fun handleMessage(msg: Message) {
|
||||
super.handleMessage(msg)
|
||||
if (2 == msg.what) {
|
||||
playerNotificationService.seekBackward(seekAmount)
|
||||
playerNotificationService.jumpBackward()
|
||||
playerNotificationService.play()
|
||||
}
|
||||
else if (msg.what >= 3) {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpForward()
|
||||
playerNotificationService.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example Using a custom action in android auto
|
||||
// override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||
// super.onCustomAction(action, extras)
|
||||
//
|
||||
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||
super.onCustomAction(action, extras)
|
||||
|
||||
when (action) {
|
||||
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
|
||||
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
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";
|
||||
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
||||
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
|
||||
@@ -20,7 +20,6 @@ import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
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.media.MediaManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
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.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
@@ -42,7 +43,10 @@ import com.google.android.exoplayer2.upstream.*
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
|
||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||
const val PLAYER_CAST = "cast-player";
|
||||
const val PLAYER_EXO = "exo-player";
|
||||
|
||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
@@ -254,6 +258,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
|
||||
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
||||
return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE
|
||||
}
|
||||
|
||||
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
||||
if (currentPlaybackSession == null) {
|
||||
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
|
||||
@@ -294,18 +302,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||
|
||||
// Example adding custom action with icon in android auto
|
||||
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider {
|
||||
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||
// }
|
||||
// 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))
|
||||
|
||||
initializeMPlayer()
|
||||
@@ -320,13 +316,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
1000 * 20 // 20s playback rebuffer
|
||||
).build()
|
||||
|
||||
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
|
||||
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
|
||||
|
||||
mPlayer = ExoPlayer.Builder(this)
|
||||
.setLoadControl(customLoadControl)
|
||||
.setSeekBackIncrementMs(seekBackTime)
|
||||
.setSeekForwardIncrementMs(seekForwardTime)
|
||||
.setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
|
||||
.setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
|
||||
.build()
|
||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||
mPlayer.addListener(PlayerListener(this))
|
||||
@@ -351,19 +344,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
isClosed = false
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
)
|
||||
val metadata = playbackSession.getMediaMetadataCompat()
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
|
||||
customActionProviders.addAll(listOf(
|
||||
SkipBackwardCustomActionProvider(),
|
||||
SkipForwardCustomActionProvider(),
|
||||
));
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
|
||||
|
||||
playbackSession.mediaPlayer = getMediaPlayer()
|
||||
|
||||
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) {
|
||||
if (playbackSession.mediaPlayer == PLAYER_CAST && playbackSession.isLocal) {
|
||||
Log.w(tag, "Cannot cast local media item - switching player")
|
||||
currentPlaybackSession = null
|
||||
switchToPlayer(false)
|
||||
playbackSession.mediaPlayer = getMediaPlayer()
|
||||
}
|
||||
|
||||
if (playbackSession.mediaPlayer == "cast-player") {
|
||||
if (playbackSession.mediaPlayer == PLAYER_CAST) {
|
||||
// If cast-player is the first player to be used
|
||||
mediaSessionConnector.setPlayer(castPlayer)
|
||||
playerNotificationManager.setPlayer(castPlayer)
|
||||
@@ -374,10 +382,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat()
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
|
||||
if (mediaItems.isEmpty()) {
|
||||
Log.e(tag, "Invalid playback session no media items to play")
|
||||
currentPlaybackSession = null
|
||||
@@ -701,6 +705,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) {
|
||||
seekPlayer(getCurrentTime() + amount)
|
||||
}
|
||||
@@ -743,7 +763,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
fun getMediaPlayer():String {
|
||||
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
|
||||
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||
}
|
||||
|
||||
fun getDeviceInfo(): DeviceInfo {
|
||||
@@ -757,6 +777,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
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 {
|
||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||
}
|
||||
@@ -784,8 +807,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
|
||||
|
||||
private val AUTO_MEDIA_ROOT = "/"
|
||||
private val ALL_ROOT = "__ALL__"
|
||||
private val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||
private val CONTINUE_ROOT = "__CONTINUE__"
|
||||
private lateinit var browseTree:BrowseTree
|
||||
|
||||
|
||||
@@ -828,8 +852,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
||||
|
||||
val flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
|
||||
result.detach()
|
||||
|
||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||
@@ -841,53 +863,67 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||
val children = libraryItems.map { libraryItem ->
|
||||
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
|
||||
|
||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
val mediaDescription = libraryItem.getMediaDescription(null)
|
||||
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
} else {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemMediaMetadata.description.mediaId }
|
||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(libraryItemMediaMetadata, progress)
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||
val description = libraryItem.getMediaDescription(progress)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads
|
||||
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||
|
||||
localBooks.forEach { localLibraryItem ->
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata()
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(mediaMetadata.description.mediaId ?: "")
|
||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||
val description = localLibraryItem.getMediaDescription(progress)
|
||||
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
localPodcasts.forEach { localLibraryItem ->
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata()
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
val mediaDescription = localLibraryItem.getMediaDescription(null)
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
result.sendResult(localBrowseItems)
|
||||
|
||||
} else { // Load categories
|
||||
mediaManager.loadAndroidAutoItems { libraryCategories ->
|
||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
||||
} else if (parentMediaId == CONTINUE_ROOT) {
|
||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||
mediaManager.serverItemsInProgress.forEach { itemInProgress ->
|
||||
val progress: MediaProgressWrapper?
|
||||
val mediaDescription:MediaDescriptionCompat
|
||||
if (itemInProgress.episode != null) {
|
||||
if (itemInProgress.isLocal) {
|
||||
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||
}
|
||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
|
||||
} else {
|
||||
if (itemInProgress.isLocal) {
|
||||
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
|
||||
}
|
||||
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
|
||||
}
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
result.sendResult(localBrowseItems)
|
||||
} else {
|
||||
mediaManager.loadAndroidAutoItems {
|
||||
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||
|
||||
val children = browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag")
|
||||
|
||||
if (flag == MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == item.description.mediaId }
|
||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(item, progress)
|
||||
MediaBrowserCompat.MediaItem(description, flag)
|
||||
} else {
|
||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
||||
}
|
||||
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
@@ -896,10 +932,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||
result.detach()
|
||||
mediaManager.loadAndroidAutoItems() { libraryCategories ->
|
||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
||||
val children = browseTree[ALL_ROOT]?.map { item ->
|
||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
mediaManager.loadAndroidAutoItems {
|
||||
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||
val children = browseTree[LIBRARIES_ROOT]?.map { item ->
|
||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
@@ -966,5 +1002,73 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
inner class SkipForwardCustomActionProvider : 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_SKIP_FORWARD,
|
||||
getContext().getString(R.string.action_skip_forward),
|
||||
R.drawable.skip_next_24
|
||||
).build()
|
||||
}
|
||||
}
|
||||
|
||||
inner class SkipBackwardCustomActionProvider : 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_SKIP_BACKWARD,
|
||||
getContext().getString(R.string.action_skip_backward),
|
||||
R.drawable.skip_previous_24
|
||||
).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,21 +189,16 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
||||
getRequest("/api/libraries/$libraryId/personalized", null, null) {
|
||||
val items = mutableListOf<LibraryCategory>()
|
||||
if (it.has("value")) {
|
||||
val array = it.getJSONArray("value")
|
||||
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||
getRequest("/api/me/items-in-progress", null, null) {
|
||||
val items = mutableListOf<ItemInProgress>()
|
||||
if (it.has("libraryItems")) {
|
||||
val array = it.getJSONArray("libraryItems")
|
||||
for (i in 0 until array.length()) {
|
||||
val jsobj = array.get(i) as JSONObject
|
||||
|
||||
val type = jsobj.get("type").toString()
|
||||
// Only support for podcast and book in android auto
|
||||
if (type == "podcast" || type == "book") {
|
||||
jsobj.put("isLocal", false)
|
||||
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
|
||||
items.add(item)
|
||||
}
|
||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||
items.add(itemInProgress)
|
||||
}
|
||||
}
|
||||
cb(items)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.127451"
|
||||
android:scaleY="1.127451"
|
||||
android:translateX="-1.5294118"
|
||||
android:translateY="-1.5294118">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.127451"
|
||||
android:scaleY="1.127451"
|
||||
android:translateX="-1.5294118"
|
||||
android:translateY="-1.5294118">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
|
||||
</vector>
|
||||
@@ -11,6 +11,8 @@
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
<color name="light_blue_200">#FF81D4FA</color>
|
||||
<color name="light_blue_600">#FF039BE5</color>
|
||||
<color name="light_blue_900">#FF01579B</color>
|
||||
<color name="background_dark">#262626</color>
|
||||
</resources>
|
||||
@@ -6,4 +6,8 @@
|
||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||
<string name="add_widget">Add widget</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>
|
||||
<string name="action_skip_forward">Skip Forward</string>
|
||||
<string name="action_skip_backward">Skip Backward</string>
|
||||
</resources>
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
@font-face {
|
||||
font-family: 'absicons';
|
||||
src: url('~static/fonts/absicons/absicons.eot?2jfq33');
|
||||
src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'),
|
||||
url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'),
|
||||
url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'),
|
||||
url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
.abs-icons {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'absicons' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-books-1:before {
|
||||
content: "\e905";
|
||||
}
|
||||
|
||||
.icon-microphone-1:before {
|
||||
content: "\e902";
|
||||
}
|
||||
|
||||
.icon-radio:before {
|
||||
content: "\e903";
|
||||
}
|
||||
|
||||
.icon-podcast:before {
|
||||
content: "\e904";
|
||||
}
|
||||
|
||||
.icon-audiobookshelf:before {
|
||||
content: "\e900";
|
||||
}
|
||||
|
||||
.icon-database:before {
|
||||
content: "\e906";
|
||||
}
|
||||
|
||||
.icon-microphone-2:before {
|
||||
content: "\e901";
|
||||
}
|
||||
|
||||
.icon-headphones:before {
|
||||
content: "\e910";
|
||||
}
|
||||
|
||||
.icon-music:before {
|
||||
content: "\e911";
|
||||
}
|
||||
|
||||
.icon-video:before {
|
||||
content: "\e914";
|
||||
}
|
||||
|
||||
.icon-microphone-3:before {
|
||||
content: "\e91e";
|
||||
}
|
||||
|
||||
.icon-book-1:before {
|
||||
content: "\e91f";
|
||||
}
|
||||
|
||||
.icon-books-2:before {
|
||||
content: "\e920";
|
||||
}
|
||||
|
||||
.icon-file-picture:before {
|
||||
content: "\e927";
|
||||
}
|
||||
|
||||
.icon-database-1:before {
|
||||
content: "\e964";
|
||||
}
|
||||
|
||||
.icon-rocket:before {
|
||||
content: "\e9a5";
|
||||
}
|
||||
|
||||
.icon-power:before {
|
||||
content: "\e9b5";
|
||||
}
|
||||
|
||||
.icon-star:before {
|
||||
content: "\e9d9";
|
||||
}
|
||||
|
||||
.icon-heart:before {
|
||||
content: "\e9da";
|
||||
}
|
||||
|
||||
.icon-rss:before {
|
||||
content: "\ea9b";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "./fonts.css";
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</a>
|
||||
<div v-if="user && currentLibrary">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" :size="4" />
|
||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||
</div>
|
||||
<div class="top-4 right-4 absolute cursor-pointer">
|
||||
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction">
|
||||
<span class="material-icons text-3xl">more_vert</span>
|
||||
</ui-dropdown-menu>
|
||||
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
</div>
|
||||
@@ -66,23 +64,24 @@
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
||||
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
|
||||
</div>
|
||||
<div id="timestamp-row" class="flex pt-0.5">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
@@ -95,6 +94,24 @@
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
|
||||
<template v-slot:chapter_track="{ item }">
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||
<div class="flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<template v-slot:lock="{ item }">
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||
<div class="flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</modals-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -139,10 +156,12 @@ export default {
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
useChapterTrack: false,
|
||||
lockUi: false,
|
||||
isLoading: false,
|
||||
touchTrackStart: false,
|
||||
dragPercent: 0,
|
||||
syncStatus: 0
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -153,17 +172,24 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
menuItems() {
|
||||
var items = []
|
||||
items.push({
|
||||
text: 'Chapter Track',
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
})
|
||||
items.push({
|
||||
text: 'Close Player',
|
||||
value: 'close',
|
||||
icon: 'close'
|
||||
})
|
||||
var items = [
|
||||
{
|
||||
text: 'Chapter Track',
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
||||
value: 'lock',
|
||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||
},
|
||||
{
|
||||
text: 'Close Player',
|
||||
value: 'close',
|
||||
icon: 'close'
|
||||
}
|
||||
]
|
||||
|
||||
return items
|
||||
},
|
||||
jumpForwardIcon() {
|
||||
@@ -179,7 +205,7 @@ export default {
|
||||
return this.$store.getters['getJumpBackwardsTime']
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
||||
@@ -310,7 +336,7 @@ export default {
|
||||
}
|
||||
},
|
||||
touchstartTrack(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
this.touchTrackStart = true
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
@@ -460,6 +486,10 @@ export default {
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||
@@ -487,7 +517,7 @@ export default {
|
||||
}
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.isLoading) return
|
||||
if (this.isLoading || this.lockUi) return
|
||||
if (!this.showFullscreen) {
|
||||
// Track not clickable on mini-player
|
||||
return
|
||||
@@ -612,18 +642,22 @@ export default {
|
||||
}
|
||||
},
|
||||
clickMenuAction(action) {
|
||||
if (action === 'chapter_track') {
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
this.showMoreMenuDialog = false
|
||||
this.$nextTick(() => {
|
||||
if (action === 'lock') {
|
||||
this.lockUi = !this.lockUi
|
||||
this.$localStore.setPlayerLock(this.lockUi)
|
||||
} else if (action === 'chapter_track') {
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
})
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
})
|
||||
},
|
||||
forceCloseDropdownMenu() {
|
||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||
@@ -705,6 +739,7 @@ export default {
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
@@ -737,8 +772,12 @@ export default {
|
||||
mounted() {
|
||||
this.updateScreenSize()
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
@@ -748,8 +787,12 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.removeEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
if (this.playbackSession) {
|
||||
console.log('[AudioPlayer] Before destroy closing playback')
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Dialog } from '@capacitor/dialog'
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isReady: false,
|
||||
settingsLoaded: false,
|
||||
audioPlayerReady: false,
|
||||
stream: null,
|
||||
download: null,
|
||||
@@ -151,6 +153,12 @@ export default {
|
||||
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
||||
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
||||
}
|
||||
|
||||
// Settings have been loaded (at least once, so it's safe to kickoff onReady)
|
||||
if (!this.settingsLoaded) {
|
||||
this.settingsLoaded = true
|
||||
this.notifyOnReady()
|
||||
}
|
||||
},
|
||||
setListeners() {
|
||||
// if (!this.$server.socket) {
|
||||
@@ -261,6 +269,18 @@ export default {
|
||||
onMediaPlayerChanged(data) {
|
||||
var mediaPlayer = data.value
|
||||
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() {
|
||||
@@ -273,6 +293,7 @@ export default {
|
||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||
|
||||
this.setListeners()
|
||||
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||
@@ -292,6 +313,7 @@ export default {
|
||||
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||
// 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('pause-item', this.pauseItem)
|
||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||
|
||||
@@ -113,6 +113,13 @@ export default {
|
||||
text: 'Local Media',
|
||||
to: '/localMedia/folders'
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
icon: 'download',
|
||||
iconOutlined: false,
|
||||
text: 'Downloads',
|
||||
to: '/downloads'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
icon: 'settings',
|
||||
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookWidth() {
|
||||
var coverSize = 100
|
||||
|
||||
@@ -69,7 +69,7 @@ export default {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
altViewEnabled() {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<!-- 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="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>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,7 @@ export default {
|
||||
return this.media.numTracks
|
||||
},
|
||||
numEpisodes() {
|
||||
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||
return this.media.numEpisodes
|
||||
},
|
||||
processingBatch() {
|
||||
@@ -351,6 +352,17 @@ export default {
|
||||
if (!this.isAltViewEnabled) return 0
|
||||
else if (!this.displaySortLine) return 3 * 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: {
|
||||
@@ -399,8 +411,26 @@ export default {
|
||||
this.selectBtnClick()
|
||||
} else if (this.recentEpisode) {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) eventBus.$emit('pause-item')
|
||||
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
if (this.streamIsPlaying) {
|
||||
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 {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
if (router) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
name() {
|
||||
return this.series.name
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
|
||||
<path
|
||||
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
|
||||
transform="translate(-557.82 -281.22)"
|
||||
/>
|
||||
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
|
||||
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
|
||||
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
|
||||
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg class="p-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<svg class="p-px" viewBox="0 0 122.877 120.596">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M68.925,69.906v50.689H53.953V69.906c-4.918-2.662-8.259-7.867-8.259-13.854 c0-8.694,7.05-15.744,15.745-15.744c8.694,0,15.745,7.05,15.745,15.744C77.184,62.039,73.843,67.244,68.925,69.906L68.925,69.906z M39.32,11.165c2.916-1.438,4.111-4.969,2.673-7.882c-1.438-2.914-4.966-4.111-7.88-2.674C22.213,6.479,12.958,16.19,7.11,27.625 c-4.32,8.445-6.783,17.842-7.08,27.325c-0.299,9.563,1.587,19.223,5.973,28.114c5.401,10.953,14.558,20.695,28.039,27.592 c2.889,1.477,6.429,0.33,7.905-2.559c1.477-2.889,0.331-6.428-2.558-7.904c-11.037-5.645-18.486-13.525-22.833-22.334 c-3.506-7.111-5.014-14.857-4.774-22.539c0.243-7.757,2.256-15.442,5.79-22.348C22.304,23.721,29.76,15.879,39.32,11.165 L39.32,11.165z M88.765,0.608c-2.914-1.438-6.443-0.24-7.881,2.674c-1.438,2.914-0.242,6.445,2.674,7.882 c9.561,4.715,17.017,12.556,21.747,21.808c3.533,6.905,5.547,14.59,5.789,22.348c0.24,7.682-1.268,15.428-4.773,22.539 c-4.347,8.809-11.796,16.689-22.833,22.334c-2.889,1.477-4.034,5.016-2.558,7.904c1.476,2.889,5.016,4.035,7.905,2.559 c13.48-6.896,22.638-16.639,28.039-27.592c4.386-8.891,6.272-18.551,5.973-28.114c-0.297-9.483-2.76-18.88-7.079-27.325 C109.919,16.19,100.665,6.479,88.765,0.608L88.765,0.608z M82.791,26.505c-2.195-1.581-5.256-1.082-6.837,1.113 c-1.58,2.195-1.082,5.256,1.113,6.837c0.885,0.637,1.753,1.352,2.604,2.134c4.971,4.583,7.919,10.694,8.538,17.16 c0.626,6.524-1.111,13.437-5.518,19.552c-0.748,1.039-1.61,2.092-2.585,3.15c-1.835,1.992-1.708,5.098,0.287,6.932 c1.994,1.834,5.099,1.705,6.933-0.287c1.18-1.279,2.286-2.641,3.315-4.072c5.862-8.139,8.166-17.4,7.322-26.197 c-0.848-8.853-4.871-17.208-11.648-23.457C85.249,28.387,84.074,27.431,82.791,26.505L82.791,26.505z M45.81,34.458 c2.195-1.581,2.694-4.642,1.113-6.837c-1.581-2.195-4.642-2.694-6.837-1.114c-1.284,0.926-2.458,1.882-3.524,2.864 c-6.778,6.25-10.801,14.604-11.649,23.457c-0.844,8.796,1.46,18.06,7.323,26.199c1.031,1.43,2.136,2.791,3.315,4.07 c1.834,1.992,4.939,2.121,6.932,0.287c1.996-1.834,2.123-4.939,0.288-6.932c-0.975-1.059-1.837-2.111-2.585-3.15 c-4.406-6.115-6.144-13.027-5.518-19.551c0.619-6.465,3.567-12.577,8.538-17.16C44.058,35.81,44.926,35.095,45.81,34.458 L45.81,34.458z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -10,11 +10,13 @@
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</slot>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
|
||||
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
|
||||
<div class="flex items-center px-3">
|
||||
<widgets-library-icon :icon="library.icon" />
|
||||
<ui-library-icon :icon="library.icon" />
|
||||
<span class="font-normal block truncate text-lg ml-4">{{ library.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
@@ -50,8 +50,9 @@ export default {
|
||||
methods: {
|
||||
async clickedOption(lib) {
|
||||
this.show = false
|
||||
if (lib.id === this.currentLibraryId) return
|
||||
await this.$store.dispatch('libraries/fetch', lib.id)
|
||||
this.$eventBus.$emit('library-changed')
|
||||
this.$eventBus.$emit('library-changed', lib.id)
|
||||
this.$localStore.setLastLibraryId(lib.id)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -39,6 +39,10 @@ export default {
|
||||
text: 'Author (Last, First)',
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: 'Published Year',
|
||||
value: 'media.metadata.publishedYear'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
|
||||
@@ -6,9 +6,26 @@
|
||||
</div>
|
||||
</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>
|
||||
<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">
|
||||
<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">
|
||||
@@ -21,8 +38,13 @@
|
||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||
</div>
|
||||
</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>
|
||||
<div v-else class="px-2 py-4">
|
||||
<div v-else class="p-4">
|
||||
<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>
|
||||
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
||||
@@ -45,7 +67,10 @@ export default {
|
||||
currentEndOfChapterTime: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
manualTimerModal: null,
|
||||
manualTimeoutMin: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
@@ -57,7 +82,7 @@ export default {
|
||||
}
|
||||
},
|
||||
timeouts() {
|
||||
return [1, 5, 10, 15, 30, 45, 60, 90]
|
||||
return [5, 10, 15, 30, 45, 60, 90]
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
return this.$secondsToTimestamp(this.currentTime)
|
||||
@@ -71,6 +96,7 @@ export default {
|
||||
clickedOption(timeoutMin) {
|
||||
var timeout = timeoutMin * 1000 * 60
|
||||
this.show = false
|
||||
this.manualTimerModal = false
|
||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
@@ -82,6 +108,12 @@ export default {
|
||||
},
|
||||
decreaseSleepTime() {
|
||||
this.$emit('decrease')
|
||||
},
|
||||
increaseManualTimeout() {
|
||||
this.manualTimeoutMin++
|
||||
},
|
||||
decreaseManualTimeout() {
|
||||
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<script>
|
||||
import Path from 'path'
|
||||
import { Archive } from 'libarchive.js/main.js'
|
||||
import { CompressedFile } from 'libarchive.js/src/compressed-file'
|
||||
|
||||
Archive.init({
|
||||
workerUrl: '/libarchive/worker-bundle.js'
|
||||
@@ -136,7 +137,11 @@ export default {
|
||||
responseType: 'blob'
|
||||
})
|
||||
const archive = await Archive.open(buff)
|
||||
this.filesObject = await archive.getFilesObject()
|
||||
const originalFilesObject = await archive.getFilesObject()
|
||||
// to support images in subfolders we need to flatten the object
|
||||
// ref: https://github.com/advplyr/audiobookshelf/issues/811
|
||||
this.filesObject = this.flattenFilesObject(originalFilesObject)
|
||||
console.log('Extracted files object', this.filesObject)
|
||||
var filenames = Object.keys(this.filesObject)
|
||||
this.parseFilenames(filenames)
|
||||
|
||||
@@ -154,6 +159,26 @@ export default {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
flattenFilesObject(filesObject) {
|
||||
const flattenObject = (obj, prefix = '') => {
|
||||
var _obj = {}
|
||||
for (const key in obj) {
|
||||
const newKey = prefix ? prefix + '/' + key : key
|
||||
if (obj[key] instanceof CompressedFile) {
|
||||
_obj[newKey] = obj[key]
|
||||
} else if (!key.startsWith('_') && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
||||
_obj = {
|
||||
..._obj,
|
||||
...flattenObject(obj[key], newKey)
|
||||
}
|
||||
} else {
|
||||
_obj[newKey] = obj[key]
|
||||
}
|
||||
}
|
||||
return _obj
|
||||
}
|
||||
return flattenObject(filesObject)
|
||||
},
|
||||
async extractXmlFile(filename) {
|
||||
console.log('extracting xml filename', filename)
|
||||
try {
|
||||
@@ -173,7 +198,7 @@ export default {
|
||||
},
|
||||
parseImageFilename(filename) {
|
||||
var basename = Path.basename(filename, Path.extname(filename))
|
||||
var numbersinpath = basename.match(/\d{1,4}/g)
|
||||
var numbersinpath = basename.match(/\d{1,5}/g)
|
||||
if (!numbersinpath || !numbersinpath.length) {
|
||||
return {
|
||||
index: -1,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<div v-if="book" class="flex w-full">
|
||||
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
|
||||
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link>
|
||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default {
|
||||
return this.$elapsedPretty(this.media.duration)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
<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-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>
|
||||
@@ -143,7 +143,12 @@ export default {
|
||||
},
|
||||
downloadClick() {
|
||||
if (this.downloadItem) return
|
||||
this.download()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
this.startDownload()
|
||||
} else {
|
||||
this.download()
|
||||
}
|
||||
},
|
||||
async download(selectedLocalFolder = null) {
|
||||
var localFolder = selectedLocalFolder
|
||||
@@ -183,7 +188,14 @@ export default {
|
||||
}
|
||||
},
|
||||
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) {
|
||||
var errorMsg = downloadRes.error || 'Unknown error'
|
||||
console.error('Download error', errorMsg)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div :class="`h-${size} w-${size} min-w-${size} text-${fontSize}`" class="flex items-center justify-center">
|
||||
<span class="abs-icons" :class="`icon-${iconToUse}`"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'audiobookshelf'
|
||||
},
|
||||
fontSize: {
|
||||
type: String,
|
||||
default: 'lg'
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
iconToUse() {
|
||||
return this.icons.includes(this.icon) ? this.icon : 'audiobookshelf'
|
||||
},
|
||||
icons() {
|
||||
return this.$store.state.globals.libraryIcons
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<div :class="`h-${size} w-${size}`">
|
||||
<component :is="iconComponentName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: String,
|
||||
size: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
iconComponentName() {
|
||||
if (this.icon === 'default') return `icons-database-svg`
|
||||
return `icons-${this.icon}-svg`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -34,10 +34,47 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; };
|
||||
AC98F95F300E46A27FAE47A4 /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */; };
|
||||
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 */; };
|
||||
E9E8814A28DA644F00D750C1 /* PlayerTimeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E8814928DA644F00D750C1 /* PlayerTimeUtils.swift */; };
|
||||
E9E8814D28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */; };
|
||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9FA07E228C82848005520B0 /* Logger.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E9E8813E28DA5DE500D750C1 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 504EC3031FED79650016851F;
|
||||
remoteInfo = Audiobookshelf;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
|
||||
@@ -62,16 +99,46 @@
|
||||
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3041FED79650016851F /* Audiobookshelf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Audiobookshelf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; 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; };
|
||||
5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Pods/Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
||||
8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.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>"; };
|
||||
B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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>"; };
|
||||
E9E8813A28DA5DE500D750C1 /* AudiobookshelfUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AudiobookshelfUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E9E8814928DA644F00D750C1 /* PlayerTimeUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeUtils.swift; sourceTree = "<group>"; };
|
||||
E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeUtilsTests.swift; sourceTree = "<group>"; };
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||
E9FA07E228C82848005520B0 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -80,7 +147,14 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
|
||||
AC98F95F300E46A27FAE47A4 /* Pods_Audiobookshelf.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E9E8813728DA5DE500D750C1 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -90,7 +164,7 @@
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
|
||||
B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -98,8 +172,11 @@
|
||||
3ABF6190280432610070250E /* player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9E8814828DA641B00D750C1 /* util */,
|
||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
|
||||
);
|
||||
path = player;
|
||||
sourceTree = "<group>";
|
||||
@@ -134,9 +211,14 @@
|
||||
children = (
|
||||
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
|
||||
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
||||
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */,
|
||||
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||
E9D5506328AC1D3F00C746DD /* server */,
|
||||
E9D5506428AC1D5800C746DD /* local */,
|
||||
E9D5506D28AC1E7400C746DD /* download */,
|
||||
);
|
||||
path = models;
|
||||
sourceTree = "<group>";
|
||||
@@ -144,12 +226,14 @@
|
||||
3AFCB5E627EA23F700ECCC05 /* util */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9FA07E228C82848005520B0 /* Logger.swift */,
|
||||
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
|
||||
3AD4FCEA280443DD006DB301 /* Database.swift */,
|
||||
3AD4FCEC28044E6C006DB301 /* Store.swift */,
|
||||
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
||||
3AB34052280829BF0039308B /* Extensions.swift */,
|
||||
3AB34054280832720039308B /* PlayerEvents.swift */,
|
||||
E9D5507228AC218300C746DD /* DaoExtensions.swift */,
|
||||
);
|
||||
path = util;
|
||||
sourceTree = "<group>";
|
||||
@@ -159,6 +243,7 @@
|
||||
children = (
|
||||
3AC8248B27F2316900529205 /* Shared */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
E9E8813B28DA5DE500D750C1 /* AudiobookshelfUnitTests */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||
@@ -168,7 +253,8 @@
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
504EC3041FED79650016851F /* Audiobookshelf.app */,
|
||||
E9E8813A28DA5DE500D750C1 /* AudiobookshelfUnitTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -195,16 +281,98 @@
|
||||
children = (
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
|
||||
8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */,
|
||||
5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
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>";
|
||||
};
|
||||
E9E8813B28DA5DE500D750C1 /* AudiobookshelfUnitTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9E8814328DA5E5900D750C1 /* Shared */,
|
||||
);
|
||||
path = AudiobookshelfUnitTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9E8814328DA5E5900D750C1 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9E8814428DA5E6000D750C1 /* player */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9E8814428DA5E6000D750C1 /* player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9E8814B28DA6B6B00D750C1 /* util */,
|
||||
);
|
||||
path = player;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9E8814828DA641B00D750C1 /* util */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9E8814928DA644F00D750C1 /* PlayerTimeUtils.swift */,
|
||||
);
|
||||
path = util;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9E8814B28DA6B6B00D750C1 /* util */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */,
|
||||
);
|
||||
path = util;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
504EC3031FED79650016851F /* Audiobookshelf */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Audiobookshelf" */;
|
||||
buildPhases = (
|
||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
@@ -216,18 +384,36 @@
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
name = Audiobookshelf;
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productReference = 504EC3041FED79650016851F /* Audiobookshelf.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
E9E8813928DA5DE500D750C1 /* AudiobookshelfUnitTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E9E8814228DA5DE500D750C1 /* Build configuration list for PBXNativeTarget "AudiobookshelfUnitTests" */;
|
||||
buildPhases = (
|
||||
E9E8813628DA5DE500D750C1 /* Sources */,
|
||||
E9E8813728DA5DE500D750C1 /* Frameworks */,
|
||||
E9E8813828DA5DE500D750C1 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E9E8813F28DA5DE500D750C1 /* PBXTargetDependency */,
|
||||
);
|
||||
name = AudiobookshelfUnitTests;
|
||||
productName = AudiobookshelfUnitTests;
|
||||
productReference = E9E8813A28DA5DE500D750C1 /* AudiobookshelfUnitTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 920;
|
||||
LastSwiftUpdateCheck = 1400;
|
||||
LastUpgradeCheck = 1330;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
@@ -235,6 +421,11 @@
|
||||
LastSwiftMigration = 1240;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
E9E8813928DA5DE500D750C1 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
ProvisioningStyle = Automatic;
|
||||
TestTargetID = 504EC3031FED79650016851F;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
@@ -250,7 +441,8 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
504EC3031FED79650016851F /* Audiobookshelf */,
|
||||
E9E8813928DA5DE500D750C1 /* AudiobookshelfUnitTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -269,6 +461,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E9E8813828DA5DE500D750C1 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
@@ -283,7 +482,7 @@
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-Audiobookshelf-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
@@ -302,7 +501,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
@@ -312,33 +511,75 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
||||
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
||||
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */,
|
||||
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */,
|
||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift 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 */,
|
||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
||||
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
||||
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
|
||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
||||
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */,
|
||||
E9E8814A28DA644F00D750C1 /* PlayerTimeUtils.swift in Sources */,
|
||||
3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
|
||||
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E9E8813628DA5DE500D750C1 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E9E8814D28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
E9E8813F28DA5DE500D750C1 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 504EC3031FED79650016851F /* Audiobookshelf */;
|
||||
targetProxy = E9E8813E28DA5DE500D750C1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
@@ -474,17 +715,17 @@
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
|
||||
baseConfigurationReference = 8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.56;
|
||||
MARKETING_VERSION = 0.9.59;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -498,17 +739,17 @@
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
|
||||
baseConfigurationReference = 5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.56;
|
||||
MARKETING_VERSION = 0.9.59;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -518,6 +759,49 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E9E8814028DA5DE500D750C1 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.AudiobookshelfUnitTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Audiobookshelf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Audiobookshelf";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E9E8814128DA5DE500D750C1 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.AudiobookshelfUnitTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Audiobookshelf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Audiobookshelf";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -530,7 +814,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Audiobookshelf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
@@ -539,6 +823,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E9E8814228DA5DE500D750C1 /* Build configuration list for PBXNativeTarget "AudiobookshelfUnitTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E9E8814028DA5DE500D750C1 /* Debug */,
|
||||
E9E8814128DA5DE500D750C1 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
|
||||
@@ -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>
|
||||
@@ -15,8 +15,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||
BuildableName = "App.app"
|
||||
BlueprintName = "App"
|
||||
BuildableName = "Audiobookshelf.app"
|
||||
BlueprintName = "Audiobookshelf"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
@@ -28,6 +28,17 @@
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E9E8813928DA5DE500D750C1"
|
||||
BuildableName = "AudiobookshelfUnitTests.xctest"
|
||||
BlueprintName = "AudiobookshelfUnitTests"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
@@ -45,8 +56,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||
BuildableName = "App.app"
|
||||
BlueprintName = "App"
|
||||
BuildableName = "Audiobookshelf.app"
|
||||
BlueprintName = "Audiobookshelf"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
@@ -62,8 +73,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||
BuildableName = "App.app"
|
||||
BlueprintName = "App"
|
||||
BuildableName = "Audiobookshelf.app"
|
||||
BlueprintName = "Audiobookshelf"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
|
||||
@@ -4,21 +4,32 @@ import RealmSwift
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
private let logger = AppLogger(category: "AppDelegate")
|
||||
|
||||
var window: UIWindow?
|
||||
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
||||
var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 1,
|
||||
migrationBlock: { migration, oldSchemaVersion in
|
||||
schemaVersion: 4,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
NSLog("Realm schema version was \(oldSchemaVersion)")
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["enableAltView"] = false
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 4) {
|
||||
self?.logger.log("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
|
||||
@@ -34,18 +45,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
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.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
logger.log("Audiobookself is now in the background")
|
||||
}
|
||||
|
||||
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.
|
||||
logger.log("Audiobookself is now in the foreground")
|
||||
}
|
||||
|
||||
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.
|
||||
logger.log("Audiobookself is now active")
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
logger.log("Audiobookself is terminating")
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
@@ -60,14 +75,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||
// Stores the completion handler for background downloads
|
||||
// The identifier of this method can be ignored at this time as we only have one background url session
|
||||
backgroundCompletionHandler = completionHandler
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
let statusBarRect = UIApplication.shared.statusBarFrame
|
||||
let statusBarRect = self.window?.windowScene?.statusBarManager?.statusBarFrame
|
||||
guard let touchPoint = event?.allTouches?.first?.location(in: self.window) else { return }
|
||||
|
||||
if statusBarRect.contains(touchPoint) {
|
||||
if statusBarRect?.contains(touchPoint) ?? false {
|
||||
NotificationCenter.default.post(name: .capacitorStatusBarTapped, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#import <Capacitor/Capacitor.h>
|
||||
|
||||
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
||||
CAP_PLUGIN_METHOD(onReady, CAPPluginReturnNone);
|
||||
|
||||
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import RealmSwift
|
||||
|
||||
@objc(AbsAudioPlayer)
|
||||
public class AbsAudioPlayer: CAPPlugin {
|
||||
private let logger = AppLogger(category: "AbsAudioPlayer")
|
||||
|
||||
private var initialPlayWhenReady = false
|
||||
private var initialPlaybackRate:Float = 1
|
||||
|
||||
override public func load() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
@@ -21,10 +23,42 @@ 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(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(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||
|
||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||
|
||||
}
|
||||
|
||||
@objc func onReady(_ call: CAPPluginCall) {
|
||||
Task { await self.restorePlaybackSession() }
|
||||
}
|
||||
|
||||
func restorePlaybackSession() async {
|
||||
do {
|
||||
// Fetch the most recent active session
|
||||
let activeSession = try Realm(queue: nil).objects(PlaybackSession.self).where({
|
||||
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}).last?.freeze()
|
||||
|
||||
if let activeSession = activeSession {
|
||||
PlayerHandler.stopPlayback(currentSessionId: activeSession.id)
|
||||
await PlayerProgress.shared.syncFromServer()
|
||||
try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate)
|
||||
}
|
||||
} catch {
|
||||
logger.error("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) {
|
||||
let libraryItemId = call.getString("libraryItemId")
|
||||
@@ -33,37 +67,47 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
let playbackRate = call.getFloat("playbackRate", 1)
|
||||
|
||||
if libraryItemId == nil {
|
||||
NSLog("provide library item id")
|
||||
logger.error("provide library item id")
|
||||
return call.resolve()
|
||||
}
|
||||
if libraryItemId!.starts(with: "local") {
|
||||
NSLog("local items are not implemnted")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
initialPlayWhenReady = playWhenReady
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
PlayerHandler.stopPlayback()
|
||||
|
||||
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
||||
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
|
||||
if (isLocalItem) {
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
||||
logger.error("Failed to get local playback session")
|
||||
return call.resolve([:])
|
||||
}
|
||||
|
||||
do {
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
call.resolve(try session.asDictionary())
|
||||
try playbackSession.save()
|
||||
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try playbackSession.asDictionary())
|
||||
} catch(let exception) {
|
||||
NSLog("failed to convert session to json")
|
||||
logger.error("Failed to start session")
|
||||
debugPrint(exception)
|
||||
call.resolve([:])
|
||||
}
|
||||
|
||||
|
||||
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
self.sendMetadata()
|
||||
} else { // Playing from the server
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { [weak self] session in
|
||||
do {
|
||||
try session.save()
|
||||
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try session.asDictionary())
|
||||
} catch(let exception) {
|
||||
self?.logger.error("Failed to start session")
|
||||
debugPrint(exception)
|
||||
call.resolve([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||
NSLog("Close playback")
|
||||
logger.log("Close playback")
|
||||
|
||||
PlayerHandler.stopPlayback()
|
||||
call.resolve()
|
||||
@@ -76,7 +120,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
])
|
||||
}
|
||||
@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()
|
||||
}
|
||||
|
||||
@@ -109,7 +158,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
|
||||
@objc func sendMetadata() {
|
||||
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() {
|
||||
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
||||
@@ -117,48 +168,49 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
|
||||
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||
|
||||
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000)
|
||||
let seconds = time/1000
|
||||
PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func increaseSleepTime(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||
|
||||
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000)
|
||||
let seconds = time/1000
|
||||
PlayerHandler.increaseSleepTime(increaseSeconds: seconds)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func setSleepTimer(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
||||
let timeSeconds = time / 1000
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
let isChapterTime = call.getBool("isChapterTime", false)
|
||||
|
||||
NSLog("chapter time: \(call.getBool("isChapterTime", false))")
|
||||
let seconds = time / 1000
|
||||
|
||||
if call.getBool("isChapterTime", false) {
|
||||
let timeToPause = timeSeconds - Int(PlayerHandler.getCurrentTime() ?? 0)
|
||||
if timeToPause < 0 { return call.resolve([ "success": false ]) }
|
||||
|
||||
PlayerHandler.sleepTimerChapterStopTime = timeSeconds
|
||||
PlayerHandler.remainingSleepTime = timeToPause
|
||||
logger.log("chapter time: \(isChapterTime)")
|
||||
if isChapterTime {
|
||||
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
||||
return call.resolve([ "success": true ])
|
||||
} else {
|
||||
PlayerHandler.setSleepTime(secondsUntilSleep: seconds)
|
||||
call.resolve([ "success": true ])
|
||||
}
|
||||
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
PlayerHandler.remainingSleepTime = timeSeconds
|
||||
call.resolve([ "success": true ])
|
||||
}
|
||||
|
||||
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
||||
PlayerHandler.remainingSleepTime = nil
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
PlayerHandler.cancelSleepTime()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
||||
call.resolve([
|
||||
"value": PlayerHandler.remainingSleepTime
|
||||
"value": PlayerHandler.getSleepTimeRemaining()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -167,31 +219,40 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
"value": PlayerHandler.getCurrentTime()
|
||||
])
|
||||
}
|
||||
|
||||
@objc func sendSleepTimerSet() {
|
||||
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 }
|
||||
logger.log("Sending local progress back to the UI")
|
||||
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||
}
|
||||
|
||||
@objc func onPlaybackFailed() {
|
||||
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
||||
let playbackSession = PlayerHandler.getPlaybackSession()
|
||||
let libraryItemId = playbackSession?.libraryItemId ?? ""
|
||||
let episodeId = playbackSession?.episodeId ?? nil
|
||||
NSLog("Forcing Transcode")
|
||||
let session = PlayerHandler.getPlaybackSession()
|
||||
let libraryItemId = session?.libraryItemId ?? ""
|
||||
let episodeId = session?.episodeId ?? nil
|
||||
logger.log("Forcing Transcode")
|
||||
|
||||
// If direct playing then fallback to transcode
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
|
||||
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
|
||||
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
|
||||
do {
|
||||
guard let self = self else { return }
|
||||
try session.save()
|
||||
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate)
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
self.sendMetadata()
|
||||
} catch(let exception) {
|
||||
NSLog("failed to convert session to json")
|
||||
self?.logger.error("Failed to start transcoded session")
|
||||
debugPrint(exception)
|
||||
}
|
||||
|
||||
self.sendMetadata()
|
||||
}
|
||||
} else {
|
||||
self.notifyListeners("onPlaybackFailed", data: [
|
||||
@@ -207,7 +268,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
"playWhenReady": playWhenReady,
|
||||
])
|
||||
}
|
||||
|
||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||
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(getLocalLibraryItemsInFolder, 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);
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import RealmSwift
|
||||
import SwiftUI
|
||||
|
||||
extension String {
|
||||
|
||||
@@ -27,6 +28,8 @@ extension String {
|
||||
|
||||
@objc(AbsDatabase)
|
||||
public class AbsDatabase: CAPPlugin {
|
||||
private let logger = AppLogger(category: "AbsDatabase")
|
||||
|
||||
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
||||
var id = call.getString("id")
|
||||
let address = call.getString("address", "")
|
||||
@@ -35,13 +38,14 @@ public class AbsDatabase: CAPPlugin {
|
||||
let token = call.getString("token", "")
|
||||
|
||||
let name = "\(address) (\(username))"
|
||||
let config = ServerConnectionConfig()
|
||||
|
||||
if id == nil {
|
||||
id = "\(address)@\(username)".toBase64()
|
||||
}
|
||||
|
||||
config.id = id!
|
||||
let config = ServerConnectionConfig()
|
||||
config.id = id ?? ""
|
||||
config.index = 0
|
||||
config.name = name
|
||||
config.address = address
|
||||
config.userId = userId
|
||||
@@ -49,7 +53,8 @@ public class AbsDatabase: CAPPlugin {
|
||||
config.token = token
|
||||
|
||||
Store.serverConfig = config
|
||||
call.resolve(convertServerConnectionConfigToJSON(config: config))
|
||||
let savedConfig = Store.serverConfig // Fetch the latest value
|
||||
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
||||
}
|
||||
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
||||
let id = call.getString("serverConnectionConfigId", "")
|
||||
@@ -75,20 +80,157 @@ public class AbsDatabase: CAPPlugin {
|
||||
}
|
||||
|
||||
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
||||
call.resolve([ "value": [] ])
|
||||
do {
|
||||
let items = Database.shared.getLocalLibraryItems()
|
||||
call.resolve([ "value": try items.asDictionaryArray()])
|
||||
} catch(let exception) {
|
||||
logger.error("error while readling local library items")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
logger.error("error while readling local library items")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
logger.error("error while readling local library items")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
||||
call.resolve([ "value": [] ])
|
||||
}
|
||||
|
||||
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
||||
call.resolve([ "value": [] ])
|
||||
do {
|
||||
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||
} catch {
|
||||
logger.error("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
|
||||
}
|
||||
|
||||
logger.log("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)
|
||||
|
||||
logger.log("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) {
|
||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||
let enableAltView = call.getBool("enableAltView") ?? false
|
||||
|
||||
@@ -7,65 +7,397 @@
|
||||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import RealmSwift
|
||||
|
||||
@objc(AbsDownloader)
|
||||
public class AbsDownloader: CAPPlugin {
|
||||
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
||||
let libraryItemId = call.getString("libraryItemId")
|
||||
let episodeId = call.getString("episodeId")
|
||||
|
||||
NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")")
|
||||
|
||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in
|
||||
if (libraryItem == nil) {
|
||||
NSLog("Library item not found")
|
||||
call.resolve()
|
||||
} else {
|
||||
NSLog("Got library item \(libraryItem!)")
|
||||
|
||||
// TODO: break out in seperate functions
|
||||
libraryItem!.media.tracks?.forEach { track in
|
||||
NSLog("TRACK \(track.contentUrl!)")
|
||||
// filename needs to be encoded otherwise would just use contentUrl
|
||||
let filename = track.metadata?.filename ?? ""
|
||||
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
let url = URL(string: urlstr)!
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
|
||||
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
|
||||
private let logger = AppLogger(category: "AbsDownloader")
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
||||
let queue = OperationQueue()
|
||||
queue.maxConcurrentOperationCount = 5
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: queue)
|
||||
}()
|
||||
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||
private var downloadItemProgress = [String: DownloadItem]()
|
||||
private var monitoringProgressTimer: Timer?
|
||||
|
||||
|
||||
// MARK: - Progress handling
|
||||
|
||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||
let realm = try Realm()
|
||||
try realm.write {
|
||||
downloadItemPart.progress = 100
|
||||
downloadItemPart.completed = true
|
||||
}
|
||||
|
||||
|
||||
call.resolve()
|
||||
do {
|
||||
// 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 }
|
||||
logger.log("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 {
|
||||
logger.error("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 {
|
||||
logger.error("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 {
|
||||
logger.log("Already monitoring progress, no need to start timer again")
|
||||
} else {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [unowned self] t in
|
||||
self.logger.log("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 {
|
||||
logger.log("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) { [weak self] libraryItem in
|
||||
guard let libraryItem = libraryItem else { self?.logger.error("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 }
|
||||
|
||||
logger.log("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) { [weak self] libraryItem in
|
||||
if let libraryItem = libraryItem {
|
||||
self?.logger.log("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 {
|
||||
logger.log("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
|
||||
logger.log("ITEM DIR \(itemDirectory)")
|
||||
|
||||
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
||||
logger.error("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 {
|
||||
AppLogger().error("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"
|
||||
}
|
||||
|
||||
@@ -10,82 +10,115 @@ import Capacitor
|
||||
|
||||
@objc(AbsFileSystem)
|
||||
public class AbsFileSystem: CAPPlugin {
|
||||
private let logger = AppLogger(category: "AbsFileSystem")
|
||||
|
||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||
let mediaType = call.getString("mediaType")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
|
||||
logger.log("Select Folder for media type \(mediaType ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||
let folderUrl = call.getString("folderUrl")
|
||||
|
||||
// TODO: Is this even necessary on iOS?
|
||||
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||
logger.log("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||
|
||||
call.resolve([
|
||||
"value": true
|
||||
])
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func scanFolder(_ call: CAPPluginCall) {
|
||||
let folderId = call.getString("folderId")
|
||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||
logger.log("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||
let folderId = call.getString("folderId")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("removeFolder \(folderId ?? "UNSET")")
|
||||
logger.log("removeFolder \(folderId ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||
logger.log("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||
logger.log("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func deleteItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localLibraryItemId = call.getString("id")
|
||||
let contentUrl = call.getString("contentUrl")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
logger.log("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||
|
||||
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 {
|
||||
logger.error("Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
|
||||
call.resolve(["success": success])
|
||||
}
|
||||
|
||||
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localLibraryItemId = call.getString("id")
|
||||
let trackLocalFileId = call.getString("trackLocalFileId")
|
||||
let contentUrl = call.getString("contentUrl")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||
logger.log("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "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 {
|
||||
logger.error("Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
|
||||
if !success {
|
||||
call.resolve(["success": success])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// PlayerTimeUtilsTests.swift
|
||||
// AudiobookshelfUnitTests
|
||||
//
|
||||
// Created by Ron Heft on 9/20/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Audiobookshelf
|
||||
|
||||
final class PlayerTimeUtilsTests: XCTestCase {
|
||||
|
||||
func testCalcSeekBackTime() {
|
||||
let currentTime: Double = 1000
|
||||
let threeSecondsAgo = Date(timeIntervalSinceNow: -3)
|
||||
let lastPlayedMs = threeSecondsAgo.timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: lastPlayedMs), 998)
|
||||
}
|
||||
|
||||
func testCalcSeekBackTimeWithZeroCurrentTime() {
|
||||
let currentTime: Double = 0
|
||||
let threeHundredSecondsAgo = Date(timeIntervalSinceNow: -300)
|
||||
let lastPlayedMs = threeHundredSecondsAgo.timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: lastPlayedMs), 0)
|
||||
}
|
||||
|
||||
func testTimeSinceLastPlayed() throws {
|
||||
let fiveSecondsAgo = Date(timeIntervalSinceNow: -5)
|
||||
let lastPlayedMs = fiveSecondsAgo.timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.timeSinceLastPlayed(lastPlayedMs)!, -5, accuracy: 1.0)
|
||||
XCTAssertNil(PlayerTimeUtils.timeSinceLastPlayed(nil))
|
||||
}
|
||||
|
||||
func testTimeToSeekBackForSinceLastPlayed() throws {
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(nil), 5, "Seeks back 5 seconds for nil")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(5), 2, "Seeks back 2 seconds for less than 6 seconds")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(11), 10, "Seeks back 10 seconds for less than 12 seconds")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(29), 15, "Seeks back 15 seconds for less than 30 seconds")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(179), 20, "Seeks back 20 seconds for less than 2 minutes")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(3599), 25, "Seeks back 25 seconds for less than 59 minutes")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(60000), 29, "Seeks back 29 seconds for anything over 59 minuts")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,7 +17,7 @@ def capacitor_pods
|
||||
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
|
||||
end
|
||||
|
||||
target 'App' do
|
||||
target 'Audiobookshelf' do
|
||||
capacitor_pods
|
||||
# Add your Pods here
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
class DeviceSettings: Object {
|
||||
@Persisted var disableAutoRewind: Bool
|
||||
@Persisted var enableAltView: Bool
|
||||
@Persisted var jumpBackwardsTime: Int
|
||||
@Persisted var jumpForwardTime: Int
|
||||
@Persisted var disableAutoRewind: Bool = false
|
||||
@Persisted var enableAltView: Bool = false
|
||||
@Persisted var jumpBackwardsTime: Int = 10
|
||||
@Persisted var jumpForwardTime: Int = 10
|
||||
}
|
||||
|
||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = false
|
||||
settings.enableAltView = false
|
||||
settings.jumpForwardTime = 10
|
||||
settings.jumpBackwardsTime = 10
|
||||
return settings
|
||||
return DeviceSettings()
|
||||
}
|
||||
|
||||
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||
return Database.realmQueue.sync {
|
||||
return [
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"jumpForwardTime": settings.jumpForwardTime
|
||||
]
|
||||
}
|
||||
return [
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"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 RealmSwift
|
||||
|
||||
struct PlaybackSession: Decodable, Encodable {
|
||||
var id: String
|
||||
var userId: String?
|
||||
var libraryItemId: String?
|
||||
var episodeId: String?
|
||||
var mediaType: String
|
||||
// var mediaMetadata: MediaTypeMetadata - It is not implemented in android?
|
||||
var chapters: [Chapter]
|
||||
var displayTitle: String?
|
||||
var displayAuthor: String?
|
||||
var coverPath: String?
|
||||
var duration: Double
|
||||
var playMethod: Int
|
||||
var startedAt: Double?
|
||||
var updatedAt: Double?
|
||||
var timeListening: Double
|
||||
var audioTracks: [AudioTrack]
|
||||
var currentTime: Double
|
||||
var libraryItem: LibraryItem
|
||||
// var localLibraryItem: LocalLibraryItem?
|
||||
var serverConnectionConfigId: String?
|
||||
var serverAddress: String?
|
||||
class PlaybackSession: Object, Codable, Deletable {
|
||||
@Persisted(primaryKey: true) var id: String = ""
|
||||
@Persisted var userId: String?
|
||||
@Persisted var libraryItemId: String?
|
||||
@Persisted var episodeId: String?
|
||||
@Persisted var mediaType: String = ""
|
||||
@Persisted var mediaMetadata: Metadata?
|
||||
@Persisted var chapters = List<Chapter>()
|
||||
@Persisted var displayTitle: String?
|
||||
@Persisted var displayAuthor: String?
|
||||
@Persisted var coverPath: String?
|
||||
@Persisted var duration: Double = 0
|
||||
@Persisted var playMethod: Int = PlayMethod.directplay.rawValue
|
||||
@Persisted var startedAt: Double?
|
||||
@Persisted var updatedAt: Double?
|
||||
@Persisted var timeListening: Double = 0
|
||||
@Persisted var audioTracks = List<AudioTrack>()
|
||||
@Persisted var currentTime: Double = 0
|
||||
@Persisted var libraryItem: LibraryItem?
|
||||
@Persisted var localLibraryItem: LocalLibraryItem?
|
||||
@Persisted var serverConnectionConfigId: String?
|
||||
@Persisted var serverAddress: String?
|
||||
@Persisted var isActiveSession = true
|
||||
@Persisted var serverUpdatedAt: Double = 0
|
||||
|
||||
var isLocal: Bool { self.localLibraryItem != nil }
|
||||
var mediaPlayer: String { "AVPlayer" }
|
||||
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
|
||||
|
||||
class ServerConnectionConfig: Object {
|
||||
@Persisted(primaryKey: true) var id: String
|
||||
@Persisted(indexed: true) var index: Int
|
||||
@Persisted var name: String
|
||||
@Persisted var address: String
|
||||
@Persisted var userId: String
|
||||
@Persisted var username: String
|
||||
@Persisted var token: String
|
||||
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||
@Persisted(indexed: true) var index: Int = 1
|
||||
@Persisted var name: String = ""
|
||||
@Persisted var address: String = ""
|
||||
@Persisted var userId: String = ""
|
||||
@Persisted var username: String = ""
|
||||
@Persisted var token: String = ""
|
||||
}
|
||||
|
||||
class ServerConnectionConfigActiveIndex: Object {
|
||||
// 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> {
|
||||
return Database.realmQueue.sync {
|
||||
return [
|
||||
"id": config.id,
|
||||
"name": config.name,
|
||||
"index": config.index,
|
||||
"address": config.address,
|
||||
"userId": config.userId,
|
||||
"username": config.username,
|
||||
"token": config.token,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"id": config.id,
|
||||
"name": config.name,
|
||||
"index": config.index,
|
||||
"address": config.address,
|
||||
"userId": config.userId,
|
||||
"username": config.username,
|
||||
"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,211 @@
|
||||
//
|
||||
// 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 coverUrl: URL? {
|
||||
if let path = self._coverContentUrl {
|
||||
return AbsDownloader.itemDownloadFolder(path: path)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var coverContentUrl: String? {
|
||||
return self.coverUrl?.absoluteString
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||