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"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 87
|
versionCode 89
|
||||||
versionName "0.9.56-beta"
|
versionName "0.9.59-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -43,6 +43,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix ".debug"
|
||||||
|
versionNameSuffix "-debug"
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
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
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.net.Uri
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import com.audiobookshelf.app.R
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.fasterxml.jackson.annotation.*
|
import com.fasterxml.jackson.annotation.*
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LibraryItem(
|
|
||||||
var id:String,
|
|
||||||
var ino:String,
|
|
||||||
var libraryId:String,
|
|
||||||
var folderId:String,
|
|
||||||
var path:String,
|
|
||||||
var relPath:String,
|
|
||||||
var mtimeMs:Long,
|
|
||||||
var ctimeMs:Long,
|
|
||||||
var birthtimeMs:Long,
|
|
||||||
var addedAt:Long,
|
|
||||||
var updatedAt:Long,
|
|
||||||
var lastScan:Long?,
|
|
||||||
var scanVersion:String?,
|
|
||||||
var isMissing:Boolean,
|
|
||||||
var isInvalid:Boolean,
|
|
||||||
var mediaType:String,
|
|
||||||
var media:MediaType,
|
|
||||||
var libraryFiles:MutableList<LibraryFile>?,
|
|
||||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
|
||||||
) : LibraryItemWrapper() {
|
|
||||||
@get:JsonIgnore
|
|
||||||
val title get() = media.metadata.title
|
|
||||||
@get:JsonIgnore
|
|
||||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCoverUri():Uri {
|
|
||||||
if (media.coverPath == null) {
|
|
||||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun checkHasTracks():Boolean {
|
|
||||||
return if (mediaType == "podcast") {
|
|
||||||
((media as Podcast).numEpisodes ?: 0) > 0
|
|
||||||
} else {
|
|
||||||
((media as Book).numTracks ?: 0) > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This auto-detects whether it is a Book or Podcast
|
// This auto-detects whether it is a Book or Podcast
|
||||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||||
@JsonSubTypes(
|
@JsonSubTypes(
|
||||||
@@ -121,7 +63,7 @@ class Podcast(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||||
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -142,7 +84,7 @@ class Podcast(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
val newEpisode = PodcastEpisode("local_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -293,24 +235,51 @@ data class PodcastEpisode(
|
|||||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat {
|
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
var coverUri:Uri = Uri.EMPTY
|
val coverUri = if(libraryItem is LocalLibraryItem) {
|
||||||
val podcast = if(libraryItem is LocalLibraryItem) {
|
libraryItem.getCoverUri()
|
||||||
coverUri = libraryItem.getCoverUri()
|
|
||||||
libraryItem.media as Podcast
|
|
||||||
} else {
|
} else {
|
||||||
coverUri = (libraryItem as LibraryItem).getCoverUri()
|
(libraryItem as LibraryItem).getCoverUri()
|
||||||
(libraryItem as LibraryItem).media as Podcast
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
val extras = Bundle()
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
if (progress != null) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
if (progress.isFinished) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
extras.putInt(
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
}.build()
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// return MediaMetadataCompat.Builder().apply {
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||||
|
//
|
||||||
|
// }.build()
|
||||||
|
val libraryItemDescription = libraryItem.getMediaDescription(null)
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(libraryItemDescription.title)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||||
@@ -37,9 +38,9 @@ data class DeviceSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DeviceData(
|
data class DeviceData(
|
||||||
@@ -92,7 +93,10 @@ data class LocalFolder(
|
|||||||
JsonSubTypes.Type(LibraryItem::class),
|
JsonSubTypes.Type(LibraryItem::class),
|
||||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||||
)
|
)
|
||||||
open class LibraryItemWrapper()
|
open class LibraryItemWrapper(var id:String) {
|
||||||
|
@JsonIgnore
|
||||||
|
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Used in Android Auto to represent a podcast episode or an audiobook in progress
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ItemInProgress(
|
||||||
|
val libraryItemWrapper: LibraryItemWrapper,
|
||||||
|
val episode: PodcastEpisode?,
|
||||||
|
val progressLastUpdate: Long,
|
||||||
|
val isLocal: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||||
|
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||||
|
|
||||||
|
var episode:PodcastEpisode? = null
|
||||||
|
if (serverItem.has("recentEpisode")) {
|
||||||
|
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
|
||||||
|
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryItem(
|
||||||
|
id:String,
|
||||||
|
var ino:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var folderId:String,
|
||||||
|
var path:String,
|
||||||
|
var relPath:String,
|
||||||
|
var mtimeMs:Long,
|
||||||
|
var ctimeMs:Long,
|
||||||
|
var birthtimeMs:Long,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var lastScan:Long?,
|
||||||
|
var scanVersion:String?,
|
||||||
|
var isMissing:Boolean,
|
||||||
|
var isInvalid:Boolean,
|
||||||
|
var mediaType:String,
|
||||||
|
var media:MediaType,
|
||||||
|
var libraryFiles:MutableList<LibraryFile>?,
|
||||||
|
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = media.metadata.title
|
||||||
|
@get:JsonIgnore
|
||||||
|
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCoverUri(): Uri {
|
||||||
|
if (media.coverPath == null) {
|
||||||
|
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun checkHasTracks():Boolean {
|
||||||
|
return if (mediaType == "podcast") {
|
||||||
|
((media as Podcast).numEpisodes ?: 0) > 0
|
||||||
|
} else {
|
||||||
|
((media as Book).numTracks ?: 0) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle(authorName)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
@@ -14,8 +12,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalLibraryItem(
|
class LocalLibraryItem(
|
||||||
var id:String,
|
id:String,
|
||||||
var folderId:String,
|
var folderId:String,
|
||||||
var basePath:String,
|
var basePath:String,
|
||||||
var absolutePath:String,
|
var absolutePath:String,
|
||||||
@@ -32,7 +30,7 @@ data class LocalLibraryItem(
|
|||||||
var serverAddress:String?,
|
var serverAddress:String?,
|
||||||
var serverUserId:String?,
|
var serverUserId:String?,
|
||||||
var libraryItemId:String?
|
var libraryItemId:String?
|
||||||
) : LibraryItemWrapper() {
|
) : LibraryItemWrapper(id) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val title get() = media.metadata.title
|
val title get() = media.metadata.title
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
@@ -98,16 +96,38 @@ data class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
val coverUri = getCoverUri()
|
val coverUri = getCoverUri()
|
||||||
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
val extras = Bundle()
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
if (progress != null) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
if (progress.isFinished) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
extras.putInt(
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
)
|
||||||
}.build()
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(authorName)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ package com.audiobookshelf.app.media
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.media.utils.MediaConstants
|
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
@@ -26,11 +22,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||||
var selectedLibraryId = ""
|
var selectedLibraryId = ""
|
||||||
|
|
||||||
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
|
|
||||||
var selectedPodcast:Podcast? = null
|
var selectedPodcast:Podcast? = null
|
||||||
var selectedLibraryItemId:String? = null
|
var selectedLibraryItemId:String? = null
|
||||||
var serverPodcastEpisodes = listOf<PodcastEpisode>()
|
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||||
|
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||||
var serverLibraries = listOf<Library>()
|
var serverLibraries = listOf<Library>()
|
||||||
var serverConfigIdUsed:String? = null
|
var serverConfigIdUsed:String? = null
|
||||||
var serverConfigLastPing:Long = 0L
|
var serverConfigLastPing:Long = 0L
|
||||||
@@ -71,7 +67,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||||
|
|
||||||
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||||
serverPodcastEpisodes = listOf()
|
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||||
serverLibraryCategories = listOf()
|
serverLibraryCategories = listOf()
|
||||||
serverLibraries = listOf()
|
serverLibraries = listOf()
|
||||||
serverLibraryItems = mutableListOf()
|
serverLibraryItems = mutableListOf()
|
||||||
@@ -80,13 +76,13 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
if (serverLibraryCategories.isNotEmpty()) {
|
if (serverItemsInProgress.isNotEmpty()) {
|
||||||
cb(serverLibraryCategories)
|
cb(serverItemsInProgress)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getLibraryCategories(libraryId) {
|
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||||
serverLibraryCategories = it
|
serverItemsInProgress = itemsInProgress
|
||||||
cb(it)
|
cb(serverItemsInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,44 +125,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||||
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||||
|
|
||||||
selectedLibraryItemWrapper = libraryItemWrapper
|
|
||||||
|
|
||||||
libraryItemWrapper?.let {
|
libraryItemWrapper?.let {
|
||||||
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
||||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||||
serverPodcastEpisodes = listOf()
|
|
||||||
cb(mutableListOf())
|
cb(mutableListOf())
|
||||||
} else {
|
} else {
|
||||||
val podcast = libraryItemWrapper.media as Podcast
|
val podcast = libraryItemWrapper.media as Podcast
|
||||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
|
||||||
selectedLibraryItemId = libraryItemWrapper.id
|
selectedLibraryItemId = libraryItemWrapper.id
|
||||||
selectedPodcast = podcast
|
selectedPodcast = podcast
|
||||||
|
|
||||||
val children = podcast.episodes?.map { podcastEpisode ->
|
val children = podcast.episodes?.map { podcastEpisode ->
|
||||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||||
|
|
||||||
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
|
|
||||||
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||||
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||||
}
|
}
|
||||||
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
||||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||||
serverPodcastEpisodes = listOf()
|
|
||||||
cb(mutableListOf())
|
cb(mutableListOf())
|
||||||
} else {
|
} else {
|
||||||
val podcast = libraryItemWrapper.media as Podcast
|
val podcast = libraryItemWrapper.media as Podcast
|
||||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
podcast.episodes?.forEach { podcastEpisode ->
|
||||||
|
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
|
||||||
|
}
|
||||||
selectedLibraryItemId = libraryItemWrapper.id
|
selectedLibraryItemId = libraryItemWrapper.id
|
||||||
selectedPodcast = podcast
|
selectedPodcast = podcast
|
||||||
|
|
||||||
val children = podcast.episodes?.map { podcastEpisode ->
|
val children = podcast.episodes?.map { podcastEpisode ->
|
||||||
|
|
||||||
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
|
|
||||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||||
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||||
@@ -275,26 +266,8 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Load currently listening category for local items
|
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||||
fun loadLocalCategory():List<LibraryCategory> {
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
|
||||||
val cats = mutableListOf<LibraryCategory>()
|
|
||||||
if (localBooks.isNotEmpty()) {
|
|
||||||
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
|
|
||||||
}
|
|
||||||
if (localPodcasts.isNotEmpty()) {
|
|
||||||
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
|
|
||||||
}
|
|
||||||
return cats
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
|
|
||||||
Log.d(tag, "Load android auto items")
|
Log.d(tag, "Load android auto items")
|
||||||
val cats = mutableListOf<LibraryCategory>()
|
|
||||||
|
|
||||||
val localCategories = loadLocalCategory()
|
|
||||||
cats.addAll(localCategories)
|
|
||||||
|
|
||||||
// Check if any valid server connection if not use locally downloaded books
|
// Check if any valid server connection if not use locally downloaded books
|
||||||
checkSetValidServerConnectionConfig { isConnected ->
|
checkSetValidServerConnectionConfig { isConnected ->
|
||||||
@@ -304,40 +277,29 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
loadLibraries { libraries ->
|
loadLibraries { libraries ->
|
||||||
if (libraries.isEmpty()) {
|
if (libraries.isEmpty()) {
|
||||||
Log.w(tag, "No libraries returned from server request")
|
Log.w(tag, "No libraries returned from server request")
|
||||||
cb(cats) // Return download category only
|
cb()
|
||||||
} else {
|
} else {
|
||||||
val library = libraries[0]
|
val library = libraries[0]
|
||||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
||||||
|
|
||||||
loadLibraryCategories(library.id) { libraryCategories ->
|
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
||||||
|
itemsInProgress.forEach {
|
||||||
// Only using book or podcast library categories for now
|
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||||
libraryCategories.forEach {
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
// Add items in continue listening to serverLibraryItems
|
|
||||||
if (it.id == "continue-listening") {
|
|
||||||
it.entities.forEach { libraryItemWrapper ->
|
|
||||||
val libraryItem = libraryItemWrapper as LibraryItem
|
|
||||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
|
||||||
serverLibraryItems.add(libraryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (it.episode != null) {
|
||||||
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
|
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
||||||
if (it.type == library.mediaType) {
|
|
||||||
// Log.d(tag, "Using library category ${it.id}")
|
|
||||||
cats.add(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cb(cats)
|
cb() // Fully loaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // Not connected/no internet sent downloaded cats only
|
} else { // Not connected to server
|
||||||
cb(cats)
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,12 +317,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
if (id.startsWith("local")) {
|
if (id.startsWith("local")) {
|
||||||
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||||
} else {
|
} else {
|
||||||
val podcastEpisode = serverPodcastEpisodes.find { it.id == id }
|
return podcastEpisodeLibraryItemMap[id]
|
||||||
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
|
|
||||||
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,41 +351,6 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMediaDescriptionFromMediaMetadata(item: MediaMetadataCompat, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
|
||||||
|
|
||||||
val extras = Bundle()
|
|
||||||
if (progress != null) {
|
|
||||||
Log.d(tag, "Has media progress for ${item.description.title} | ${progress}")
|
|
||||||
if (progress.isFinished) {
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
|
||||||
)
|
|
||||||
extras.putDouble(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "No media progress for ${item.description.title} | ${item.description.mediaId}")
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return MediaDescriptionCompat.Builder()
|
|
||||||
.setMediaId(item.description.mediaId)
|
|
||||||
.setTitle(item.description.title)
|
|
||||||
.setIconUri(item.description.iconUri)
|
|
||||||
.setSubtitle(item.description.subtitle)
|
|
||||||
.setExtras(extras).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
||||||
val lhsLength = lhs.length + 1
|
val lhsLength = lhs.length + 1
|
||||||
val rhsLength = rhs.length + 1
|
val rhsLength = rhs.length + 1
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.audiobookshelf.app.data.*
|
|||||||
|
|
||||||
class BrowseTree(
|
class BrowseTree(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
libraryCategories: List<LibraryCategory>,
|
itemsInProgress: List<ItemInProgress>,
|
||||||
libraries: List<Library>
|
libraries: List<Library>
|
||||||
) {
|
) {
|
||||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||||
@@ -21,8 +21,7 @@ class BrowseTree(
|
|||||||
* @param drawableId - drawable res id
|
* @param drawableId - drawable res id
|
||||||
* @return - uri
|
* @return - uri
|
||||||
*/
|
*/
|
||||||
fun getUriToDrawable(context: Context,
|
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||||
@AnyRes drawableId: Int): Uri {
|
|
||||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||||
@@ -32,38 +31,26 @@ class BrowseTree(
|
|||||||
init {
|
init {
|
||||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||||
|
|
||||||
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
|
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
// Server continue Listening cat
|
if (!itemsInProgress.isEmpty()) {
|
||||||
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
|
rootList += continueListeningMetadata
|
||||||
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
|
|
||||||
val libraryItem = liw as LibraryItem
|
|
||||||
libraryItem.getMediaMetadata()
|
|
||||||
}
|
|
||||||
if (continueListeningMediaMetadata.isNotEmpty()) {
|
|
||||||
rootList += continueReadingMetadata
|
|
||||||
}
|
|
||||||
continueListeningMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
|
||||||
children += it
|
|
||||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraries.isNotEmpty()) {
|
if (libraries.isNotEmpty()) {
|
||||||
@@ -78,23 +65,6 @@ class BrowseTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootList += downloadsMetadata
|
rootList += downloadsMetadata
|
||||||
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
|
|
||||||
localBooksCat.entities.forEach { libc ->
|
|
||||||
val libraryItem = libc as LocalLibraryItem
|
|
||||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
|
||||||
children += libraryItem.getMediaMetadata()
|
|
||||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
|
|
||||||
localPodcastsCat.entities.forEach { libc ->
|
|
||||||
val libraryItem = libc as LocalLibraryItem
|
|
||||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
|
||||||
children += libraryItem.getMediaMetadata()
|
|
||||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Message
|
import android.os.Message
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
import com.audiobookshelf.app.data.PodcastEpisode
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -21,7 +19,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
|
|
||||||
private var mediaButtonClickCount: Int = 0
|
private var mediaButtonClickCount: Int = 0
|
||||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
var mediaButtonClickTimeout: Long = 1000 //ms
|
||||||
var seekAmount: Long = 20000 //ms
|
|
||||||
|
|
||||||
override fun onPrepare() {
|
override fun onPrepare() {
|
||||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||||
@@ -75,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.skipToPrevious()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
override fun onSkipToNext() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.skipToNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFastForward() {
|
override fun onFastForward() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRewind() {
|
override fun onRewind() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
@@ -179,10 +176,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
playerNotificationService.closePlayback()
|
playerNotificationService.closePlayback()
|
||||||
@@ -226,22 +223,24 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
super.handleMessage(msg)
|
super.handleMessage(msg)
|
||||||
if (2 == msg.what) {
|
if (2 == msg.what) {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
else if (msg.what >= 3) {
|
else if (msg.what >= 3) {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example Using a custom action in android auto
|
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||||
// override fun onCustomAction(action: String?, extras: Bundle?) {
|
super.onCustomAction(action, extras)
|
||||||
// super.onCustomAction(action, extras)
|
|
||||||
//
|
when (action) {
|
||||||
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) {
|
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||||
//
|
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||||
// }
|
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.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
@@ -30,9 +29,11 @@ import com.audiobookshelf.app.data.DeviceInfo
|
|||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaManager
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
||||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import com.google.android.exoplayer2.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
@@ -42,7 +43,10 @@ import com.google.android.exoplayer2.upstream.*
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
|
||||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||||
|
const val PLAYER_CAST = "cast-player";
|
||||||
|
const val PLAYER_EXO = "exo-player";
|
||||||
|
|
||||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
|
|
||||||
@@ -254,6 +258,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||||
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(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 {
|
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
||||||
if (currentPlaybackSession == null) {
|
if (currentPlaybackSession == null) {
|
||||||
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
|
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
|
||||||
@@ -294,18 +302,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||||
|
|
||||||
// Example adding custom action with icon in android auto
|
|
||||||
// mediaSessionConnector.setCustomActionProviders(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))
|
mediaSession.setCallback(MediaSessionCallback(this))
|
||||||
|
|
||||||
initializeMPlayer()
|
initializeMPlayer()
|
||||||
@@ -320,13 +316,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
1000 * 20 // 20s playback rebuffer
|
1000 * 20 // 20s playback rebuffer
|
||||||
).build()
|
).build()
|
||||||
|
|
||||||
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
|
|
||||||
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
|
|
||||||
|
|
||||||
mPlayer = ExoPlayer.Builder(this)
|
mPlayer = ExoPlayer.Builder(this)
|
||||||
.setLoadControl(customLoadControl)
|
.setLoadControl(customLoadControl)
|
||||||
.setSeekBackIncrementMs(seekBackTime)
|
.setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
|
||||||
.setSeekForwardIncrementMs(seekForwardTime)
|
.setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
|
||||||
.build()
|
.build()
|
||||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||||
mPlayer.addListener(PlayerListener(this))
|
mPlayer.addListener(PlayerListener(this))
|
||||||
@@ -351,19 +344,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isClosed = false
|
isClosed = false
|
||||||
|
val customActionProviders = mutableListOf(
|
||||||
|
JumpBackwardCustomActionProvider(),
|
||||||
|
JumpForwardCustomActionProvider(),
|
||||||
|
)
|
||||||
|
val metadata = playbackSession.getMediaMetadataCompat()
|
||||||
|
mediaSession.setMetadata(metadata)
|
||||||
|
val mediaItems = playbackSession.getMediaItems()
|
||||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||||
initialPlaybackRate = playbackRate
|
initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
|
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
|
||||||
|
customActionProviders.addAll(listOf(
|
||||||
|
SkipBackwardCustomActionProvider(),
|
||||||
|
SkipForwardCustomActionProvider(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
|
||||||
|
|
||||||
playbackSession.mediaPlayer = getMediaPlayer()
|
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")
|
Log.w(tag, "Cannot cast local media item - switching player")
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
switchToPlayer(false)
|
switchToPlayer(false)
|
||||||
playbackSession.mediaPlayer = getMediaPlayer()
|
playbackSession.mediaPlayer = getMediaPlayer()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playbackSession.mediaPlayer == "cast-player") {
|
if (playbackSession.mediaPlayer == PLAYER_CAST) {
|
||||||
// If cast-player is the first player to be used
|
// If cast-player is the first player to be used
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
mediaSessionConnector.setPlayer(castPlayer)
|
||||||
playerNotificationManager.setPlayer(castPlayer)
|
playerNotificationManager.setPlayer(castPlayer)
|
||||||
@@ -374,10 +382,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||||
|
|
||||||
val metadata = playbackSession.getMediaMetadataCompat()
|
|
||||||
mediaSession.setMetadata(metadata)
|
|
||||||
val mediaItems = playbackSession.getMediaItems()
|
|
||||||
|
|
||||||
if (mediaItems.isEmpty()) {
|
if (mediaItems.isEmpty()) {
|
||||||
Log.e(tag, "Invalid playback session no media items to play")
|
Log.e(tag, "Invalid playback session no media items to play")
|
||||||
currentPlaybackSession = null
|
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) {
|
fun seekForward(amount: Long) {
|
||||||
seekPlayer(getCurrentTime() + amount)
|
seekPlayer(getCurrentTime() + amount)
|
||||||
}
|
}
|
||||||
@@ -743,7 +763,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getMediaPlayer():String {
|
fun getMediaPlayer():String {
|
||||||
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
|
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDeviceInfo(): DeviceInfo {
|
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)
|
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
|
|
||||||
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||||
}
|
}
|
||||||
@@ -784,8 +807,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
|
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
|
||||||
|
|
||||||
private val AUTO_MEDIA_ROOT = "/"
|
private val AUTO_MEDIA_ROOT = "/"
|
||||||
private val ALL_ROOT = "__ALL__"
|
|
||||||
private val LIBRARIES_ROOT = "__LIBRARIES__"
|
private val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||||
|
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
|
private val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
private lateinit var browseTree:BrowseTree
|
private lateinit var browseTree:BrowseTree
|
||||||
|
|
||||||
|
|
||||||
@@ -828,8 +852,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
||||||
|
|
||||||
val flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
|
||||||
|
|
||||||
result.detach()
|
result.detach()
|
||||||
|
|
||||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||||
@@ -841,53 +863,67 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||||
val children = libraryItems.map { libraryItem ->
|
val children = libraryItems.map { libraryItem ->
|
||||||
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
|
|
||||||
|
|
||||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||||
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
val mediaDescription = libraryItem.getMediaDescription(null)
|
||||||
|
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
} else {
|
} else {
|
||||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemMediaMetadata.description.mediaId }
|
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(libraryItemMediaMetadata, progress)
|
val description = libraryItem.getMediaDescription(progress)
|
||||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads
|
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||||
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
|
|
||||||
localBooks.forEach { localLibraryItem ->
|
localBooks.forEach { localLibraryItem ->
|
||||||
val mediaMetadata = localLibraryItem.getMediaMetadata()
|
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(mediaMetadata.description.mediaId ?: "")
|
val description = localLibraryItem.getMediaDescription(progress)
|
||||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
|
||||||
|
|
||||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
localPodcasts.forEach { localLibraryItem ->
|
localPodcasts.forEach { localLibraryItem ->
|
||||||
val mediaMetadata = localLibraryItem.getMediaMetadata()
|
val mediaDescription = localLibraryItem.getMediaDescription(null)
|
||||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.sendResult(localBrowseItems)
|
result.sendResult(localBrowseItems)
|
||||||
|
|
||||||
} else { // Load categories
|
} else if (parentMediaId == CONTINUE_ROOT) {
|
||||||
mediaManager.loadAndroidAutoItems { libraryCategories ->
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
mediaManager.serverItemsInProgress.forEach { itemInProgress ->
|
||||||
|
val progress: MediaProgressWrapper?
|
||||||
|
val mediaDescription:MediaDescriptionCompat
|
||||||
|
if (itemInProgress.episode != null) {
|
||||||
|
if (itemInProgress.isLocal) {
|
||||||
|
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||||
|
} else {
|
||||||
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||||
|
}
|
||||||
|
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
|
||||||
|
} else {
|
||||||
|
if (itemInProgress.isLocal) {
|
||||||
|
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
||||||
|
} else {
|
||||||
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
|
||||||
|
}
|
||||||
|
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
|
||||||
|
}
|
||||||
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
result.sendResult(localBrowseItems)
|
||||||
|
} else {
|
||||||
|
mediaManager.loadAndroidAutoItems {
|
||||||
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
|
|
||||||
val children = browseTree[parentMediaId]?.map { item ->
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag")
|
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
||||||
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
if (flag == MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) {
|
|
||||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == item.description.mediaId }
|
|
||||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(item, progress)
|
|
||||||
MediaBrowserCompat.MediaItem(description, flag)
|
|
||||||
} else {
|
|
||||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
@@ -896,10 +932,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||||
result.detach()
|
result.detach()
|
||||||
mediaManager.loadAndroidAutoItems() { libraryCategories ->
|
mediaManager.loadAndroidAutoItems {
|
||||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
val children = browseTree[ALL_ROOT]?.map { item ->
|
val children = browseTree[LIBRARIES_ROOT]?.map { item ->
|
||||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
@@ -966,5 +1002,73 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
|
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner class JumpBackwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_BACKWARD,
|
||||||
|
getContext().getString(R.string.action_jump_backward),
|
||||||
|
R.drawable.exo_icon_rewind
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class JumpForwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_FORWARD,
|
||||||
|
getContext().getString(R.string.action_jump_forward),
|
||||||
|
R.drawable.exo_icon_fastforward
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
getRequest("/api/libraries/$libraryId/personalized", null, null) {
|
getRequest("/api/me/items-in-progress", null, null) {
|
||||||
val items = mutableListOf<LibraryCategory>()
|
val items = mutableListOf<ItemInProgress>()
|
||||||
if (it.has("value")) {
|
if (it.has("libraryItems")) {
|
||||||
val array = it.getJSONArray("value")
|
val array = it.getJSONArray("libraryItems")
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val jsobj = array.get(i) as JSONObject
|
val jsobj = array.get(i) as JSONObject
|
||||||
|
|
||||||
val type = jsobj.get("type").toString()
|
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||||
// Only support for podcast and book in android auto
|
items.add(itemInProgress)
|
||||||
if (type == "podcast" || type == "book") {
|
|
||||||
jsobj.put("isLocal", false)
|
|
||||||
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
|
|
||||||
items.add(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cb(items)
|
cb(items)
|
||||||
|
|||||||
@@ -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="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,5 @@
|
|||||||
<color name="light_blue_200">#FF81D4FA</color>
|
<color name="light_blue_200">#FF81D4FA</color>
|
||||||
<color name="light_blue_600">#FF039BE5</color>
|
<color name="light_blue_600">#FF039BE5</color>
|
||||||
<color name="light_blue_900">#FF01579B</color>
|
<color name="light_blue_900">#FF01579B</color>
|
||||||
|
<color name="background_dark">#262626</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -6,4 +6,8 @@
|
|||||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||||
<string name="add_widget">Add widget</string>
|
<string name="add_widget">Add widget</string>
|
||||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
<string name="action_jump_backward">Jump Backward</string>
|
||||||
|
<string name="action_skip_forward">Skip Forward</string>
|
||||||
|
<string name="action_skip_backward">Skip Backward</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.google.gms:google-services:4.3.5'
|
classpath 'com.google.gms:google-services:4.3.10'
|
||||||
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
|
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -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 "./fonts.css";
|
||||||
@import './defaultStyles.css';
|
@import './defaultStyles.css';
|
||||||
|
@import './absicons.css';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #262626;
|
background-color: #262626;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div v-if="user && currentLibrary">
|
<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">
|
<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>
|
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-4 right-4 absolute cursor-pointer">
|
<div class="top-4 right-4 absolute cursor-pointer">
|
||||||
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction">
|
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
<span class="material-icons text-3xl">more_vert</span>
|
|
||||||
</ui-dropdown-menu>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,23 +64,24 @@
|
|||||||
|
|
||||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||||
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||||
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
||||||
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack">
|
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
|
||||||
</div>
|
</div>
|
||||||
<div id="timestamp-row" class="flex pt-0.5">
|
<div id="timestamp-row" class="flex pt-0.5">
|
||||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||||
@@ -95,6 +94,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
|
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
|
||||||
|
<template v-slot:chapter_track="{ item }">
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||||
|
<div class="flex items-center px-3">
|
||||||
|
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||||
|
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<template v-slot:lock="{ item }">
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||||
|
<div class="flex items-center px-3">
|
||||||
|
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
|
||||||
|
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</modals-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -139,10 +156,12 @@ export default {
|
|||||||
touchStartTime: 0,
|
touchStartTime: 0,
|
||||||
touchEndY: 0,
|
touchEndY: 0,
|
||||||
useChapterTrack: false,
|
useChapterTrack: false,
|
||||||
|
lockUi: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
touchTrackStart: false,
|
touchTrackStart: false,
|
||||||
dragPercent: 0,
|
dragPercent: 0,
|
||||||
syncStatus: 0
|
syncStatus: 0,
|
||||||
|
showMoreMenuDialog: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -153,17 +172,24 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
menuItems() {
|
menuItems() {
|
||||||
var items = []
|
var items = [
|
||||||
items.push({
|
{
|
||||||
text: 'Chapter Track',
|
text: 'Chapter Track',
|
||||||
value: 'chapter_track',
|
value: 'chapter_track',
|
||||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||||
})
|
},
|
||||||
items.push({
|
{
|
||||||
text: 'Close Player',
|
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
||||||
value: 'close',
|
value: 'lock',
|
||||||
icon: 'close'
|
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
text: 'Close Player',
|
||||||
|
value: 'close',
|
||||||
|
icon: 'close'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
jumpForwardIcon() {
|
jumpForwardIcon() {
|
||||||
@@ -179,7 +205,7 @@ export default {
|
|||||||
return this.$store.getters['getJumpBackwardsTime']
|
return this.$store.getters['getJumpBackwardsTime']
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
||||||
@@ -310,7 +336,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
touchstartTrack(e) {
|
touchstartTrack(e) {
|
||||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
|
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||||
this.touchTrackStart = true
|
this.touchTrackStart = true
|
||||||
},
|
},
|
||||||
selectChapter(chapter) {
|
selectChapter(chapter) {
|
||||||
@@ -460,6 +486,10 @@ export default {
|
|||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||||
|
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
if (this.useChapterTrack) {
|
if (this.useChapterTrack) {
|
||||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||||
@@ -487,7 +517,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickTrack(e) {
|
clickTrack(e) {
|
||||||
if (this.isLoading) return
|
if (this.isLoading || this.lockUi) return
|
||||||
if (!this.showFullscreen) {
|
if (!this.showFullscreen) {
|
||||||
// Track not clickable on mini-player
|
// Track not clickable on mini-player
|
||||||
return
|
return
|
||||||
@@ -612,18 +642,22 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickMenuAction(action) {
|
clickMenuAction(action) {
|
||||||
if (action === 'chapter_track') {
|
this.showMoreMenuDialog = false
|
||||||
this.useChapterTrack = !this.useChapterTrack
|
this.$nextTick(() => {
|
||||||
|
if (action === 'lock') {
|
||||||
|
this.lockUi = !this.lockUi
|
||||||
|
this.$localStore.setPlayerLock(this.lockUi)
|
||||||
|
} else if (action === 'chapter_track') {
|
||||||
|
this.useChapterTrack = !this.useChapterTrack
|
||||||
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
})
|
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
} else if (action === 'close') {
|
||||||
} else if (action === 'close') {
|
this.closePlayback()
|
||||||
this.closePlayback()
|
}
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
forceCloseDropdownMenu() {
|
forceCloseDropdownMenu() {
|
||||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||||
@@ -705,6 +739,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||||
|
this.lockUi = await this.$localStore.getPlayerLock()
|
||||||
|
|
||||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||||
@@ -737,8 +772,12 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
if (screen.orientation) {
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||||
}
|
}
|
||||||
|
window.addEventListener('resize', this.screenOrientationChange)
|
||||||
|
|
||||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||||
document.body.addEventListener('touchstart', this.touchstart)
|
document.body.addEventListener('touchstart', this.touchstart)
|
||||||
@@ -748,8 +787,12 @@ export default {
|
|||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (screen.orientation) {
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||||
}
|
}
|
||||||
|
window.removeEventListener('resize', this.screenOrientationChange)
|
||||||
|
|
||||||
if (this.playbackSession) {
|
if (this.playbackSession) {
|
||||||
console.log('[AudioPlayer] Before destroy closing playback')
|
console.log('[AudioPlayer] Before destroy closing playback')
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { Dialog } from '@capacitor/dialog'
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
isReady: false,
|
||||||
|
settingsLoaded: false,
|
||||||
audioPlayerReady: false,
|
audioPlayerReady: false,
|
||||||
stream: null,
|
stream: null,
|
||||||
download: null,
|
download: null,
|
||||||
@@ -151,6 +153,12 @@ export default {
|
|||||||
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
||||||
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings have been loaded (at least once, so it's safe to kickoff onReady)
|
||||||
|
if (!this.settingsLoaded) {
|
||||||
|
this.settingsLoaded = true
|
||||||
|
this.notifyOnReady()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setListeners() {
|
setListeners() {
|
||||||
// if (!this.$server.socket) {
|
// if (!this.$server.socket) {
|
||||||
@@ -261,6 +269,18 @@ export default {
|
|||||||
onMediaPlayerChanged(data) {
|
onMediaPlayerChanged(data) {
|
||||||
var mediaPlayer = data.value
|
var mediaPlayer = data.value
|
||||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
this.$store.commit('setMediaPlayer', mediaPlayer)
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// The UI is reporting elsewhere we are ready
|
||||||
|
this.isReady = true
|
||||||
|
this.notifyOnReady()
|
||||||
|
},
|
||||||
|
notifyOnReady() {
|
||||||
|
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||||
|
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||||
|
if ( this.settingsLoaded && this.isReady ) {
|
||||||
|
AbsAudioPlayer.onReady()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -273,6 +293,7 @@ export default {
|
|||||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||||
|
|
||||||
this.setListeners()
|
this.setListeners()
|
||||||
|
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||||
@@ -292,6 +313,7 @@ export default {
|
|||||||
// this.$server.socket.off('stream_ready', this.streamReady)
|
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||||
// this.$server.socket.off('stream_reset', this.streamReset)
|
// this.$server.socket.off('stream_reset', this.streamReset)
|
||||||
// }
|
// }
|
||||||
|
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ export default {
|
|||||||
text: 'Local Media',
|
text: 'Local Media',
|
||||||
to: '/localMedia/folders'
|
to: '/localMedia/folders'
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
icon: 'download',
|
||||||
|
iconOutlined: false,
|
||||||
|
text: 'Downloads',
|
||||||
|
to: '/downloads'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default {
|
|||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
var coverSize = 100
|
var coverSize = 100
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default {
|
|||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
altViewEnabled() {
|
altViewEnabled() {
|
||||||
return this.$store.getters['getAltViewEnabled']
|
return this.$store.getters['getAltViewEnabled']
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
coverWidth() {
|
coverWidth() {
|
||||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- No progress shown for collapsed series in library -->
|
||||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,6 +164,7 @@ export default {
|
|||||||
return this.media.numTracks
|
return this.media.numTracks
|
||||||
},
|
},
|
||||||
numEpisodes() {
|
numEpisodes() {
|
||||||
|
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||||
return this.media.numEpisodes
|
return this.media.numEpisodes
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
@@ -351,6 +352,17 @@ export default {
|
|||||||
if (!this.isAltViewEnabled) return 0
|
if (!this.isAltViewEnabled) return 0
|
||||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
return 4.25 * this.sizeMultiplier
|
return 4.25 * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
showHasLocalDownload() {
|
||||||
|
if (this.localLibraryItem || this.isLocal) {
|
||||||
|
if (this.recentEpisode && !this.isLocal) {
|
||||||
|
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
|
return !!localEpisode
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -399,8 +411,26 @@ export default {
|
|||||||
this.selectBtnClick()
|
this.selectBtnClick()
|
||||||
} else if (this.recentEpisode) {
|
} else if (this.recentEpisode) {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
if (this.streamIsPlaying) eventBus.$emit('pause-item')
|
if (this.streamIsPlaying) {
|
||||||
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
eventBus.$emit('pause-item')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.localLibraryItem) {
|
||||||
|
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
|
if (localEpisode) {
|
||||||
|
// Play episode locally
|
||||||
|
eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.localLibraryItemId,
|
||||||
|
episodeId: localEpisode.id,
|
||||||
|
serverLibraryItemId: this.libraryItemId,
|
||||||
|
serverEpisodeId: this.recentEpisode.id
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||||
} else {
|
} else {
|
||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
if (router) {
|
if (router) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
name() {
|
name() {
|
||||||
return this.series.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>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||||
<div class="relative flex items-center px-3">
|
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
<div class="relative flex items-center px-3">
|
||||||
</div>
|
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||||
</li>
|
</div>
|
||||||
|
</li>
|
||||||
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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)">
|
<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 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">
|
<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>
|
<span class="font-normal block truncate text-lg ml-4">{{ library.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -50,8 +50,9 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async clickedOption(lib) {
|
async clickedOption(lib) {
|
||||||
this.show = false
|
this.show = false
|
||||||
|
if (lib.id === this.currentLibraryId) return
|
||||||
await this.$store.dispatch('libraries/fetch', lib.id)
|
await this.$store.dispatch('libraries/fetch', lib.id)
|
||||||
this.$eventBus.$emit('library-changed')
|
this.$eventBus.$emit('library-changed', lib.id)
|
||||||
this.$localStore.setLastLibraryId(lib.id)
|
this.$localStore.setLastLibraryId(lib.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export default {
|
|||||||
text: 'Author (Last, First)',
|
text: 'Author (Last, First)',
|
||||||
value: 'media.metadata.authorNameLF'
|
value: 'media.metadata.authorNameLF'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Published Year',
|
||||||
|
value: 'media.metadata.publishedYear'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Added At',
|
text: 'Added At',
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
|
|||||||
@@ -6,9 +6,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div
|
||||||
|
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||||
|
@click="
|
||||||
|
show = false
|
||||||
|
manualTimerModal = false
|
||||||
|
"
|
||||||
|
>
|
||||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<ul v-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<div v-if="manualTimerModal" class="p-4">
|
||||||
|
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||||
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex my-2 justify-between">
|
||||||
|
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||||
|
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||||
|
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||||
|
</div>
|
||||||
|
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="timeout in timeouts">
|
<template v-for="timeout in timeouts">
|
||||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
@@ -21,8 +38,13 @@
|
|||||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal block truncate text-lg text-center">Manual sleep timer</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-else class="px-2 py-4">
|
<div v-else class="p-4">
|
||||||
<div class="flex my-2 justify-between">
|
<div class="flex my-2 justify-between">
|
||||||
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||||
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
||||||
@@ -45,7 +67,10 @@ export default {
|
|||||||
currentEndOfChapterTime: Number
|
currentEndOfChapterTime: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
manualTimerModal: null,
|
||||||
|
manualTimeoutMin: 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
@@ -57,7 +82,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
timeouts() {
|
timeouts() {
|
||||||
return [1, 5, 10, 15, 30, 45, 60, 90]
|
return [5, 10, 15, 30, 45, 60, 90]
|
||||||
},
|
},
|
||||||
timeRemainingPretty() {
|
timeRemainingPretty() {
|
||||||
return this.$secondsToTimestamp(this.currentTime)
|
return this.$secondsToTimestamp(this.currentTime)
|
||||||
@@ -71,6 +96,7 @@ export default {
|
|||||||
clickedOption(timeoutMin) {
|
clickedOption(timeoutMin) {
|
||||||
var timeout = timeoutMin * 1000 * 60
|
var timeout = timeoutMin * 1000 * 60
|
||||||
this.show = false
|
this.show = false
|
||||||
|
this.manualTimerModal = false
|
||||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||||
},
|
},
|
||||||
cancelSleepTimer() {
|
cancelSleepTimer() {
|
||||||
@@ -82,6 +108,12 @@ export default {
|
|||||||
},
|
},
|
||||||
decreaseSleepTime() {
|
decreaseSleepTime() {
|
||||||
this.$emit('decrease')
|
this.$emit('decrease')
|
||||||
|
},
|
||||||
|
increaseManualTimeout() {
|
||||||
|
this.manualTimeoutMin++
|
||||||
|
},
|
||||||
|
decreaseManualTimeout() {
|
||||||
|
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Path from 'path'
|
import Path from 'path'
|
||||||
import { Archive } from 'libarchive.js/main.js'
|
import { Archive } from 'libarchive.js/main.js'
|
||||||
|
import { CompressedFile } from 'libarchive.js/src/compressed-file'
|
||||||
|
|
||||||
Archive.init({
|
Archive.init({
|
||||||
workerUrl: '/libarchive/worker-bundle.js'
|
workerUrl: '/libarchive/worker-bundle.js'
|
||||||
@@ -136,7 +137,11 @@ export default {
|
|||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
})
|
})
|
||||||
const archive = await Archive.open(buff)
|
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)
|
var filenames = Object.keys(this.filesObject)
|
||||||
this.parseFilenames(filenames)
|
this.parseFilenames(filenames)
|
||||||
|
|
||||||
@@ -154,6 +159,26 @@ export default {
|
|||||||
this.loading = false
|
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) {
|
async extractXmlFile(filename) {
|
||||||
console.log('extracting xml filename', filename)
|
console.log('extracting xml filename', filename)
|
||||||
try {
|
try {
|
||||||
@@ -173,7 +198,7 @@ export default {
|
|||||||
},
|
},
|
||||||
parseImageFilename(filename) {
|
parseImageFilename(filename) {
|
||||||
var basename = Path.basename(filename, Path.extname(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) {
|
if (!numbersinpath || !numbersinpath.length) {
|
||||||
return {
|
return {
|
||||||
index: -1,
|
index: -1,
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||||
<div v-if="book" class="flex w-full">
|
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
|
||||||
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link>
|
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export default {
|
|||||||
return this.$elapsedPretty(this.media.duration)
|
return this.$elapsedPretty(this.media.duration)
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
if (this.bookCoverAspectRatio === 1) return 50
|
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" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
|
|
||||||
<div v-if="!isIos && userCanDownload">
|
<div v-if="userCanDownload">
|
||||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||||
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||||
@@ -143,7 +143,12 @@ export default {
|
|||||||
},
|
},
|
||||||
downloadClick() {
|
downloadClick() {
|
||||||
if (this.downloadItem) return
|
if (this.downloadItem) return
|
||||||
this.download()
|
if (this.isIos) {
|
||||||
|
// no local folders on iOS
|
||||||
|
this.startDownload()
|
||||||
|
} else {
|
||||||
|
this.download()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
var localFolder = selectedLocalFolder
|
var localFolder = selectedLocalFolder
|
||||||
@@ -183,7 +188,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async startDownload(localFolder) {
|
async startDownload(localFolder) {
|
||||||
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id })
|
var payload = {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: this.episode.id
|
||||||
|
}
|
||||||
|
if (localFolder) {
|
||||||
|
payload.localFolderId = localFolder.id
|
||||||
|
}
|
||||||
|
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
|
||||||
if (downloadRes && downloadRes.error) {
|
if (downloadRes && downloadRes.error) {
|
||||||
var errorMsg = downloadRes.error || 'Unknown error'
|
var errorMsg = downloadRes.error || 'Unknown error'
|
||||||
console.error('Download error', errorMsg)
|
console.error('Download error', errorMsg)
|
||||||
|
|||||||
@@ -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 */; };
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
AC98F95F300E46A27FAE47A4 /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */; };
|
||||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; };
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||||
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||||
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||||
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */; };
|
||||||
|
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504D28AC1B0700C746DD /* AudioFile.swift */; };
|
||||||
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504F28AC1B3E00C746DD /* Author.swift */; };
|
||||||
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505128AC1B5D00C746DD /* Chapter.swift */; };
|
||||||
|
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505328AC1B7900C746DD /* AudioTrack.swift */; };
|
||||||
|
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505528AC1BFA00C746DD /* FileMetadata.swift */; };
|
||||||
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505728AC1C1A00C746DD /* Library.swift */; };
|
||||||
|
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505928AC1C4500C746DD /* Folder.swift */; };
|
||||||
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505B28AC1C6200C746DD /* LibraryFile.swift */; };
|
||||||
|
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505D28AC1C8500C746DD /* MediaProgress.swift */; };
|
||||||
|
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */; };
|
||||||
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506128AC1CC900C746DD /* PlayerState.swift */; };
|
||||||
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */; };
|
||||||
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */; };
|
||||||
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506928AC1DF100C746DD /* LocalFile.swift */; };
|
||||||
|
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */; };
|
||||||
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */; };
|
||||||
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; };
|
||||||
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; };
|
||||||
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; };
|
||||||
|
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */; };
|
||||||
|
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 */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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>"; };
|
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>"; };
|
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -80,7 +147,14 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
|
AC98F95F300E46A27FAE47A4 /* Pods_Audiobookshelf.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
E9E8813728DA5DE500D750C1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -90,7 +164,7 @@
|
|||||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
|
B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -98,8 +172,11 @@
|
|||||||
3ABF6190280432610070250E /* player */ = {
|
3ABF6190280432610070250E /* player */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E9E8814828DA641B00D750C1 /* util */,
|
||||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||||
|
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
||||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
||||||
|
E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
|
||||||
);
|
);
|
||||||
path = player;
|
path = player;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -134,9 +211,14 @@
|
|||||||
children = (
|
children = (
|
||||||
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
||||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
||||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
|
|
||||||
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
||||||
|
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */,
|
||||||
|
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||||
|
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||||
|
E9D5506328AC1D3F00C746DD /* server */,
|
||||||
|
E9D5506428AC1D5800C746DD /* local */,
|
||||||
|
E9D5506D28AC1E7400C746DD /* download */,
|
||||||
);
|
);
|
||||||
path = models;
|
path = models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -144,12 +226,14 @@
|
|||||||
3AFCB5E627EA23F700ECCC05 /* util */ = {
|
3AFCB5E627EA23F700ECCC05 /* util */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E9FA07E228C82848005520B0 /* Logger.swift */,
|
||||||
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
|
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
|
||||||
3AD4FCEA280443DD006DB301 /* Database.swift */,
|
3AD4FCEA280443DD006DB301 /* Database.swift */,
|
||||||
3AD4FCEC28044E6C006DB301 /* Store.swift */,
|
3AD4FCEC28044E6C006DB301 /* Store.swift */,
|
||||||
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
||||||
3AB34052280829BF0039308B /* Extensions.swift */,
|
3AB34052280829BF0039308B /* Extensions.swift */,
|
||||||
3AB34054280832720039308B /* PlayerEvents.swift */,
|
3AB34054280832720039308B /* PlayerEvents.swift */,
|
||||||
|
E9D5507228AC218300C746DD /* DaoExtensions.swift */,
|
||||||
);
|
);
|
||||||
path = util;
|
path = util;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -159,6 +243,7 @@
|
|||||||
children = (
|
children = (
|
||||||
3AC8248B27F2316900529205 /* Shared */,
|
3AC8248B27F2316900529205 /* Shared */,
|
||||||
504EC3061FED79650016851F /* App */,
|
504EC3061FED79650016851F /* App */,
|
||||||
|
E9E8813B28DA5DE500D750C1 /* AudiobookshelfUnitTests */,
|
||||||
504EC3051FED79650016851F /* Products */,
|
504EC3051FED79650016851F /* Products */,
|
||||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||||
@@ -168,7 +253,8 @@
|
|||||||
504EC3051FED79650016851F /* Products */ = {
|
504EC3051FED79650016851F /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
504EC3041FED79650016851F /* App.app */,
|
504EC3041FED79650016851F /* Audiobookshelf.app */,
|
||||||
|
E9E8813A28DA5DE500D750C1 /* AudiobookshelfUnitTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -195,16 +281,98 @@
|
|||||||
children = (
|
children = (
|
||||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
|
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
|
||||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
|
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
|
||||||
|
8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */,
|
||||||
|
5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Pods;
|
name = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E9D5506328AC1D3F00C746DD /* server */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5504528AC1A3900C746DD /* LibraryItem.swift */,
|
||||||
|
E9D5504728AC1A7A00C746DD /* MediaType.swift */,
|
||||||
|
E9D5504928AC1AA600C746DD /* Metadata.swift */,
|
||||||
|
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */,
|
||||||
|
E9D5504D28AC1B0700C746DD /* AudioFile.swift */,
|
||||||
|
E9D5504F28AC1B3E00C746DD /* Author.swift */,
|
||||||
|
E9D5505128AC1B5D00C746DD /* Chapter.swift */,
|
||||||
|
E9D5505328AC1B7900C746DD /* AudioTrack.swift */,
|
||||||
|
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */,
|
||||||
|
E9D5505728AC1C1A00C746DD /* Library.swift */,
|
||||||
|
E9D5505928AC1C4500C746DD /* Folder.swift */,
|
||||||
|
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
||||||
|
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
||||||
|
);
|
||||||
|
path = server;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E9D5506428AC1D5800C746DD /* local */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */,
|
||||||
|
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */,
|
||||||
|
E9D5506928AC1DF100C746DD /* LocalFile.swift */,
|
||||||
|
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */,
|
||||||
|
);
|
||||||
|
path = local;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E9D5506D28AC1E7400C746DD /* download */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */,
|
||||||
|
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */,
|
||||||
|
);
|
||||||
|
path = download;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
504EC3031FED79650016851F /* App */ = {
|
504EC3031FED79650016851F /* Audiobookshelf */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Audiobookshelf" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
|
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
|
||||||
504EC3001FED79650016851F /* Sources */,
|
504EC3001FED79650016851F /* Sources */,
|
||||||
@@ -216,18 +384,36 @@
|
|||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = App;
|
name = Audiobookshelf;
|
||||||
productName = App;
|
productName = App;
|
||||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
productReference = 504EC3041FED79650016851F /* Audiobookshelf.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
504EC2FC1FED79650016851F /* Project object */ = {
|
504EC2FC1FED79650016851F /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastSwiftUpdateCheck = 920;
|
LastSwiftUpdateCheck = 1400;
|
||||||
LastUpgradeCheck = 1330;
|
LastUpgradeCheck = 1330;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
@@ -235,6 +421,11 @@
|
|||||||
LastSwiftMigration = 1240;
|
LastSwiftMigration = 1240;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
|
E9E8813928DA5DE500D750C1 = {
|
||||||
|
CreatedOnToolsVersion = 14.0;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
TestTargetID = 504EC3031FED79650016851F;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||||
@@ -250,7 +441,8 @@
|
|||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
504EC3031FED79650016851F /* App */,
|
504EC3031FED79650016851F /* Audiobookshelf */,
|
||||||
|
E9E8813928DA5DE500D750C1 /* AudiobookshelfUnitTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -269,6 +461,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
E9E8813828DA5DE500D750C1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
@@ -283,7 +482,7 @@
|
|||||||
);
|
);
|
||||||
name = "[CP] Check Pods Manifest.lock";
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
|
"$(DERIVED_FILE_DIR)/Pods-Audiobookshelf-checkManifestLockResult.txt",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
@@ -302,7 +501,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
@@ -312,33 +511,75 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
||||||
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
|
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
||||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||||
|
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||||
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||||
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||||
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
||||||
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
||||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
||||||
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
||||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
||||||
|
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */,
|
||||||
|
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */,
|
||||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||||
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||||
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||||
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||||
|
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||||
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||||
|
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
|
||||||
|
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */,
|
||||||
|
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
|
||||||
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||||
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
||||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
||||||
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
||||||
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
||||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
|
|
||||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
||||||
|
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */,
|
||||||
|
E9E8814A28DA644F00D750C1 /* PlayerTimeUtils.swift in Sources */,
|
||||||
3AB34055280832720039308B /* PlayerEvents.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
E9E8813F28DA5DE500D750C1 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 504EC3031FED79650016851F /* Audiobookshelf */;
|
||||||
|
targetProxy = E9E8813E28DA5DE500D750C1 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||||
isa = PBXVariantGroup;
|
isa = PBXVariantGroup;
|
||||||
@@ -474,17 +715,17 @@
|
|||||||
};
|
};
|
||||||
504EC3171FED79650016851F /* Debug */ = {
|
504EC3171FED79650016851F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
|
baseConfigurationReference = 8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.56;
|
MARKETING_VERSION = 0.9.59;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -498,17 +739,17 @@
|
|||||||
};
|
};
|
||||||
504EC3181FED79650016851F /* Release */ = {
|
504EC3181FED79650016851F /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
|
baseConfigurationReference = 5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.56;
|
MARKETING_VERSION = 0.9.59;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -518,6 +759,49 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -530,7 +814,7 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Audiobookshelf" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
504EC3171FED79650016851F /* Debug */,
|
504EC3171FED79650016851F /* Debug */,
|
||||||
@@ -539,6 +823,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
E9E8814228DA5DE500D750C1 /* Build configuration list for PBXNativeTarget "AudiobookshelfUnitTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E9E8814028DA5DE500D750C1 /* Debug */,
|
||||||
|
E9E8814128DA5DE500D750C1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
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
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||||
BuildableName = "App.app"
|
BuildableName = "Audiobookshelf.app"
|
||||||
BlueprintName = "App"
|
BlueprintName = "Audiobookshelf"
|
||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
@@ -28,6 +28,17 @@
|
|||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<Testables>
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E9E8813928DA5DE500D750C1"
|
||||||
|
BuildableName = "AudiobookshelfUnitTests.xctest"
|
||||||
|
BlueprintName = "AudiobookshelfUnitTests"
|
||||||
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
@@ -45,8 +56,8 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||||
BuildableName = "App.app"
|
BuildableName = "Audiobookshelf.app"
|
||||||
BlueprintName = "App"
|
BlueprintName = "Audiobookshelf"
|
||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
@@ -62,8 +73,8 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||||
BuildableName = "App.app"
|
BuildableName = "Audiobookshelf.app"
|
||||||
BlueprintName = "App"
|
BlueprintName = "Audiobookshelf"
|
||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
|||||||
@@ -4,21 +4,32 @@ import RealmSwift
|
|||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
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 {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
let configuration = Realm.Configuration(
|
let configuration = Realm.Configuration(
|
||||||
schemaVersion: 1,
|
schemaVersion: 4,
|
||||||
migrationBlock: { migration, oldSchemaVersion in
|
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||||
if (oldSchemaVersion < 1) {
|
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
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["enableAltView"] = false
|
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
|
Realm.Configuration.defaultConfiguration = configuration
|
||||||
@@ -34,18 +45,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||||
|
logger.log("Audiobookself is now in the background")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||||
|
logger.log("Audiobookself is now in the foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
|
logger.log("Audiobookself is now active")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
|
logger.log("Audiobookself is terminating")
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
@@ -60,14 +75,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// tracking app url opens, make sure to keep this call
|
// tracking app url opens, make sure to keep this call
|
||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||||
|
// Stores the completion handler for background downloads
|
||||||
|
// The identifier of this method can be ignored at this time as we only have one background url session
|
||||||
|
backgroundCompletionHandler = completionHandler
|
||||||
|
}
|
||||||
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
super.touchesBegan(touches, with: event)
|
super.touchesBegan(touches, with: event)
|
||||||
|
|
||||||
let statusBarRect = UIApplication.shared.statusBarFrame
|
let statusBarRect = self.window?.windowScene?.statusBarManager?.statusBarFrame
|
||||||
guard let touchPoint = event?.allTouches?.first?.location(in: self.window) else { return }
|
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)
|
NotificationCenter.default.post(name: .capacitorStatusBarTapped, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
#import <Capacitor/Capacitor.h>
|
#import <Capacitor/Capacitor.h>
|
||||||
|
|
||||||
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
||||||
|
CAP_PLUGIN_METHOD(onReady, CAPPluginReturnNone);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
@objc(AbsAudioPlayer)
|
@objc(AbsAudioPlayer)
|
||||||
public class AbsAudioPlayer: CAPPlugin {
|
public class AbsAudioPlayer: CAPPlugin {
|
||||||
|
private let logger = AppLogger(category: "AbsAudioPlayer")
|
||||||
|
|
||||||
private var initialPlayWhenReady = false
|
private var initialPlayWhenReady = false
|
||||||
private var initialPlaybackRate:Float = 1
|
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
@@ -21,10 +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(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||||
|
|
||||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func onReady(_ call: CAPPluginCall) {
|
||||||
|
Task { await self.restorePlaybackSession() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePlaybackSession() async {
|
||||||
|
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) {
|
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
||||||
let libraryItemId = call.getString("libraryItemId")
|
let libraryItemId = call.getString("libraryItemId")
|
||||||
@@ -33,37 +67,47 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
let playbackRate = call.getFloat("playbackRate", 1)
|
let playbackRate = call.getFloat("playbackRate", 1)
|
||||||
|
|
||||||
if libraryItemId == nil {
|
if libraryItemId == nil {
|
||||||
NSLog("provide library item id")
|
logger.error("provide library item id")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
if libraryItemId!.starts(with: "local") {
|
|
||||||
NSLog("local items are not implemnted")
|
|
||||||
return call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
initialPlayWhenReady = playWhenReady
|
|
||||||
initialPlaybackRate = playbackRate
|
|
||||||
|
|
||||||
PlayerHandler.stopPlayback()
|
PlayerHandler.stopPlayback()
|
||||||
|
|
||||||
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
|
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
|
||||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
if (isLocalItem) {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||||
|
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||||
|
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
||||||
|
logger.error("Failed to get local playback session")
|
||||||
|
return call.resolve([:])
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
self.sendPlaybackSession(session: try session.asDictionary())
|
try playbackSession.save()
|
||||||
call.resolve(try session.asDictionary())
|
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
call.resolve(try playbackSession.asDictionary())
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to convert session to json")
|
logger.error("Failed to start session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
|
} else { // Playing from the server
|
||||||
|
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { [weak self] session in
|
||||||
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
do {
|
||||||
self.sendMetadata()
|
try session.save()
|
||||||
|
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
call.resolve(try session.asDictionary())
|
||||||
|
} catch(let exception) {
|
||||||
|
self?.logger.error("Failed to start session")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve([:])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||||
NSLog("Close playback")
|
logger.log("Close playback")
|
||||||
|
|
||||||
PlayerHandler.stopPlayback()
|
PlayerHandler.stopPlayback()
|
||||||
call.resolve()
|
call.resolve()
|
||||||
@@ -76,7 +120,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.setPlaybackSpeed(speed: call.getFloat("value", 1.0))
|
let playbackRate = call.getFloat("value", 1.0)
|
||||||
|
let settings = PlayerSettings.main()
|
||||||
|
try? settings.update {
|
||||||
|
settings.playbackRate = playbackRate
|
||||||
|
}
|
||||||
|
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +158,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
|
|
||||||
@objc func sendMetadata() {
|
@objc func sendMetadata() {
|
||||||
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
||||||
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
|
if let metadata = PlayerHandler.getMetdata() {
|
||||||
|
self.notifyListeners("onMetadata", data: metadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@objc func sendPlaybackClosedEvent() {
|
@objc func sendPlaybackClosedEvent() {
|
||||||
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
||||||
@@ -117,48 +168,49 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
|
|
||||||
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
|
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
|
||||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||||
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||||
|
|
||||||
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000)
|
let seconds = time/1000
|
||||||
|
PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func increaseSleepTime(_ call: CAPPluginCall) {
|
@objc func increaseSleepTime(_ call: CAPPluginCall) {
|
||||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||||
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||||
|
|
||||||
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000)
|
let seconds = time/1000
|
||||||
|
PlayerHandler.increaseSleepTime(increaseSeconds: seconds)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSleepTimer(_ call: CAPPluginCall) {
|
@objc func setSleepTimer(_ call: CAPPluginCall) {
|
||||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||||
let timeSeconds = time / 1000
|
let isChapterTime = call.getBool("isChapterTime", false)
|
||||||
|
|
||||||
NSLog("chapter time: \(call.getBool("isChapterTime", false))")
|
let seconds = time / 1000
|
||||||
|
|
||||||
if call.getBool("isChapterTime", false) {
|
logger.log("chapter time: \(isChapterTime)")
|
||||||
let timeToPause = timeSeconds - Int(PlayerHandler.getCurrentTime() ?? 0)
|
if isChapterTime {
|
||||||
if timeToPause < 0 { return call.resolve([ "success": false ]) }
|
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
||||||
|
|
||||||
PlayerHandler.sleepTimerChapterStopTime = timeSeconds
|
|
||||||
PlayerHandler.remainingSleepTime = timeToPause
|
|
||||||
return call.resolve([ "success": true ])
|
return call.resolve([ "success": true ])
|
||||||
|
} else {
|
||||||
|
PlayerHandler.setSleepTime(secondsUntilSleep: seconds)
|
||||||
|
call.resolve([ "success": true ])
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
|
||||||
PlayerHandler.remainingSleepTime = timeSeconds
|
|
||||||
call.resolve([ "success": true ])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.remainingSleepTime = nil
|
PlayerHandler.cancelSleepTime()
|
||||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
||||||
call.resolve([
|
call.resolve([
|
||||||
"value": PlayerHandler.remainingSleepTime
|
"value": PlayerHandler.getSleepTimeRemaining()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,31 +219,40 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
"value": PlayerHandler.getCurrentTime()
|
"value": PlayerHandler.getCurrentTime()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func sendSleepTimerSet() {
|
@objc func sendSleepTimerSet() {
|
||||||
self.notifyListeners("onSleepTimerSet", data: [
|
self.notifyListeners("onSleepTimerSet", data: [
|
||||||
"value": PlayerHandler.remainingSleepTime
|
"value": PlayerHandler.getSleepTimeRemaining()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func onLocalMediaProgressUpdate() {
|
||||||
|
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||||
|
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
||||||
|
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
||||||
|
logger.log("Sending local progress back to the UI")
|
||||||
|
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func onPlaybackFailed() {
|
@objc func onPlaybackFailed() {
|
||||||
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
||||||
let playbackSession = PlayerHandler.getPlaybackSession()
|
let session = PlayerHandler.getPlaybackSession()
|
||||||
let libraryItemId = playbackSession?.libraryItemId ?? ""
|
let libraryItemId = session?.libraryItemId ?? ""
|
||||||
let episodeId = playbackSession?.episodeId ?? nil
|
let episodeId = session?.episodeId ?? nil
|
||||||
NSLog("Forcing Transcode")
|
logger.log("Forcing Transcode")
|
||||||
|
|
||||||
// If direct playing then fallback to transcode
|
// If direct playing then fallback to transcode
|
||||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
|
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
|
||||||
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
|
|
||||||
|
|
||||||
do {
|
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.sendPlaybackSession(session: try session.asDictionary())
|
||||||
|
self.sendMetadata()
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to convert session to json")
|
self?.logger.error("Failed to start transcoded session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sendMetadata()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.notifyListeners("onPlaybackFailed", data: [
|
self.notifyListeners("onPlaybackFailed", data: [
|
||||||
@@ -207,7 +268,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
"playWhenReady": playWhenReady,
|
"playWhenReady": playWhenReady,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||||
self.notifyListeners("onPlaybackSession", data: session)
|
self.notifyListeners("onPlaybackSession", data: session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PlayerError: String, Error {
|
||||||
|
case libraryItemIdNotSpecified = "No libraryItemId provided on session"
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
|
|||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ extension String {
|
|||||||
|
|
||||||
@objc(AbsDatabase)
|
@objc(AbsDatabase)
|
||||||
public class AbsDatabase: CAPPlugin {
|
public class AbsDatabase: CAPPlugin {
|
||||||
|
private let logger = AppLogger(category: "AbsDatabase")
|
||||||
|
|
||||||
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
var id = call.getString("id")
|
var id = call.getString("id")
|
||||||
let address = call.getString("address", "")
|
let address = call.getString("address", "")
|
||||||
@@ -35,13 +38,14 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
let token = call.getString("token", "")
|
let token = call.getString("token", "")
|
||||||
|
|
||||||
let name = "\(address) (\(username))"
|
let name = "\(address) (\(username))"
|
||||||
let config = ServerConnectionConfig()
|
|
||||||
|
|
||||||
if id == nil {
|
if id == nil {
|
||||||
id = "\(address)@\(username)".toBase64()
|
id = "\(address)@\(username)".toBase64()
|
||||||
}
|
}
|
||||||
|
|
||||||
config.id = id!
|
let config = ServerConnectionConfig()
|
||||||
|
config.id = id ?? ""
|
||||||
|
config.index = 0
|
||||||
config.name = name
|
config.name = name
|
||||||
config.address = address
|
config.address = address
|
||||||
config.userId = userId
|
config.userId = userId
|
||||||
@@ -49,7 +53,8 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
config.token = token
|
config.token = token
|
||||||
|
|
||||||
Store.serverConfig = config
|
Store.serverConfig = config
|
||||||
call.resolve(convertServerConnectionConfigToJSON(config: config))
|
let savedConfig = Store.serverConfig // Fetch the latest value
|
||||||
|
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
||||||
}
|
}
|
||||||
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
let id = call.getString("serverConnectionConfigId", "")
|
let id = call.getString("serverConnectionConfigId", "")
|
||||||
@@ -75,20 +80,157 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
do {
|
||||||
|
let items = Database.shared.getLocalLibraryItems()
|
||||||
|
call.resolve([ "value": try items.asDictionaryArray()])
|
||||||
|
} catch(let exception) {
|
||||||
|
logger.error("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
call.resolve()
|
do {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
||||||
|
switch item {
|
||||||
|
case .some(let foundItem):
|
||||||
|
call.resolve(try foundItem.asDictionary())
|
||||||
|
default:
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
} catch(let exception) {
|
||||||
|
logger.error("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
||||||
call.resolve()
|
do {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
|
||||||
|
switch item {
|
||||||
|
case .some(let foundItem):
|
||||||
|
call.resolve(try foundItem.asDictionary())
|
||||||
|
default:
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
} catch(let exception) {
|
||||||
|
logger.error("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
call.resolve([ "value": [] ])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
do {
|
||||||
|
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||||
|
} catch {
|
||||||
|
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) {
|
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
||||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||||
let enableAltView = call.getBool("enableAltView") ?? false
|
let enableAltView = call.getBool("enableAltView") ?? false
|
||||||
|
|||||||
@@ -7,65 +7,397 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
@objc(AbsDownloader)
|
@objc(AbsDownloader)
|
||||||
public class AbsDownloader: CAPPlugin {
|
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||||
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
|
||||||
let libraryItemId = call.getString("libraryItemId")
|
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
let episodeId = call.getString("episodeId")
|
|
||||||
|
private let logger = AppLogger(category: "AbsDownloader")
|
||||||
NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")")
|
|
||||||
|
private lazy var session: URLSession = {
|
||||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in
|
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
||||||
if (libraryItem == nil) {
|
let queue = OperationQueue()
|
||||||
NSLog("Library item not found")
|
queue.maxConcurrentOperationCount = 5
|
||||||
call.resolve()
|
return URLSession(configuration: config, delegate: self, delegateQueue: queue)
|
||||||
} else {
|
}()
|
||||||
NSLog("Got library item \(libraryItem!)")
|
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||||
|
private var downloadItemProgress = [String: DownloadItem]()
|
||||||
// TODO: break out in seperate functions
|
private var monitoringProgressTimer: Timer?
|
||||||
libraryItem!.media.tracks?.forEach { track in
|
|
||||||
NSLog("TRACK \(track.contentUrl!)")
|
|
||||||
// filename needs to be encoded otherwise would just use contentUrl
|
// MARK: - Progress handling
|
||||||
let filename = track.metadata?.filename ?? ""
|
|
||||||
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
let url = URL(string: urlstr)!
|
let realm = try Realm()
|
||||||
|
try realm.write {
|
||||||
|
downloadItemPart.progress = 100
|
||||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
downloadItemPart.completed = true
|
||||||
let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItemId!)")
|
}
|
||||||
NSLog("ITEM DIR \(itemDirectory)")
|
|
||||||
|
|
||||||
// Create library item directory
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false)
|
|
||||||
} catch {
|
|
||||||
NSLog("Failed to CREATE LI DIRECTORY \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output filename
|
|
||||||
let trackFilename = itemDirectory.appendingPathComponent("\(filename)")
|
|
||||||
|
|
||||||
let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in
|
|
||||||
|
|
||||||
guard let fileURL = urlOrNil else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
NSLog("Download TMP file URL \(fileURL)")
|
|
||||||
let imageData = try Data(contentsOf:fileURL)
|
|
||||||
try imageData.write(to: trackFilename)
|
|
||||||
NSLog("Download written to \(trackFilename)")
|
|
||||||
} catch {
|
|
||||||
NSLog("FILE ERROR: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadTask.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
do {
|
||||||
call.resolve()
|
// Move the downloaded file into place
|
||||||
|
guard let destinationUrl = downloadItemPart.destinationURL else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: destinationUrl)
|
||||||
|
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||||
|
try realm.write {
|
||||||
|
downloadItemPart.moved = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try realm.write {
|
||||||
|
downloadItemPart.failed = true
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||||
|
if let error = error {
|
||||||
|
try Realm().write {
|
||||||
|
downloadItemPart.completed = true
|
||||||
|
downloadItemPart.failed = true
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
|
// Calculate the download percentage
|
||||||
|
let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100
|
||||||
|
// Only update the progress if we received accurate progress data
|
||||||
|
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||||
|
try Realm().write {
|
||||||
|
downloadItemPart.progress = percentDownloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when downloads are complete on the background thread
|
||||||
|
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
|
||||||
|
let backgroundCompletionHandler =
|
||||||
|
appDelegate.backgroundCompletionHandler else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backgroundCompletionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||||
|
do {
|
||||||
|
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
||||||
|
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)
|
@objc(AbsFileSystem)
|
||||||
public class AbsFileSystem: CAPPlugin {
|
public class AbsFileSystem: CAPPlugin {
|
||||||
|
private let logger = AppLogger(category: "AbsFileSystem")
|
||||||
|
|
||||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||||
let mediaType = call.getString("mediaType")
|
let mediaType = call.getString("mediaType")
|
||||||
|
|
||||||
// TODO: Implement
|
logger.log("Select Folder for media type \(mediaType ?? "UNSET")")
|
||||||
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
|
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||||
let folderUrl = call.getString("folderUrl")
|
let folderUrl = call.getString("folderUrl")
|
||||||
|
|
||||||
// TODO: Is this even necessary on iOS?
|
logger.log("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||||
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
|
||||||
|
|
||||||
call.resolve([
|
call.unavailable("Not available on iOS")
|
||||||
"value": true
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func scanFolder(_ call: CAPPluginCall) {
|
@objc func scanFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
// TODO: Implement
|
logger.log("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
|
|
||||||
// TODO: Implement
|
logger.log("removeFolder \(folderId ?? "UNSET")")
|
||||||
NSLog("removeFolder \(folderId ?? "UNSET")")
|
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
|
||||||
// TODO: Implement
|
logger.log("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||||
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
// TODO: Implement
|
logger.log("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func deleteItem(_ call: CAPPluginCall) {
|
@objc func deleteItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("id")
|
||||||
let contentUrl = call.getString("contentUrl")
|
let contentUrl = call.getString("contentUrl")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
|
||||||
|
|
||||||
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) {
|
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("id")
|
||||||
let trackLocalFileId = call.getString("trackLocalFileId")
|
let trackLocalFileId = call.getString("trackLocalFileId")
|
||||||
let contentUrl = call.getString("contentUrl")
|
|
||||||
|
|
||||||
// TODO: Implement
|
logger.log("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
||||||
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
|
||||||
|
|
||||||
call.resolve()
|
var success = false
|
||||||
|
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||||
|
do {
|
||||||
|
try item.update {
|
||||||
|
do {
|
||||||
|
if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) {
|
||||||
|
try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath)
|
||||||
|
item.realm?.delete(item.localFiles[fileIndex])
|
||||||
|
if item.isPodcast, let media = item.media {
|
||||||
|
if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) {
|
||||||
|
media.episodes.remove(at: episodeIndex)
|
||||||
|
}
|
||||||
|
item.media = media
|
||||||
|
}
|
||||||
|
call.resolve(try item.asDictionary())
|
||||||
|
success = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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'
|
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'App' do
|
target 'Audiobookshelf' do
|
||||||
capacitor_pods
|
capacitor_pods
|
||||||
# Add your Pods here
|
# 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
|
import RealmSwift
|
||||||
|
|
||||||
class DeviceSettings: Object {
|
class DeviceSettings: Object {
|
||||||
@Persisted var disableAutoRewind: Bool
|
@Persisted var disableAutoRewind: Bool = false
|
||||||
@Persisted var enableAltView: Bool
|
@Persisted var enableAltView: Bool = false
|
||||||
@Persisted var jumpBackwardsTime: Int
|
@Persisted var jumpBackwardsTime: Int = 10
|
||||||
@Persisted var jumpForwardTime: Int
|
@Persisted var jumpForwardTime: Int = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||||
let settings = DeviceSettings()
|
return DeviceSettings()
|
||||||
settings.disableAutoRewind = false
|
|
||||||
settings.enableAltView = false
|
|
||||||
settings.jumpForwardTime = 10
|
|
||||||
settings.jumpBackwardsTime = 10
|
|
||||||
return settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||||
return Database.realmQueue.sync {
|
return [
|
||||||
return [
|
"disableAutoRewind": settings.disableAutoRewind,
|
||||||
"disableAutoRewind": settings.disableAutoRewind,
|
"enableAltView": settings.enableAltView,
|
||||||
"enableAltView": settings.enableAltView,
|
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
"jumpForwardTime": settings.jumpForwardTime
|
||||||
"jumpForwardTime": settings.jumpForwardTime
|
]
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// PlaybackMetadata.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class PlaybackMetadata: Codable {
|
||||||
|
var duration: Double = 0
|
||||||
|
var currentTime: Double = 0
|
||||||
|
var playerState: PlayerState = PlayerState.IDLE
|
||||||
|
}
|
||||||
@@ -6,27 +6,156 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
struct PlaybackSession: Decodable, Encodable {
|
class PlaybackSession: Object, Codable, Deletable {
|
||||||
var id: String
|
@Persisted(primaryKey: true) var id: String = ""
|
||||||
var userId: String?
|
@Persisted var userId: String?
|
||||||
var libraryItemId: String?
|
@Persisted var libraryItemId: String?
|
||||||
var episodeId: String?
|
@Persisted var episodeId: String?
|
||||||
var mediaType: String
|
@Persisted var mediaType: String = ""
|
||||||
// var mediaMetadata: MediaTypeMetadata - It is not implemented in android?
|
@Persisted var mediaMetadata: Metadata?
|
||||||
var chapters: [Chapter]
|
@Persisted var chapters = List<Chapter>()
|
||||||
var displayTitle: String?
|
@Persisted var displayTitle: String?
|
||||||
var displayAuthor: String?
|
@Persisted var displayAuthor: String?
|
||||||
var coverPath: String?
|
@Persisted var coverPath: String?
|
||||||
var duration: Double
|
@Persisted var duration: Double = 0
|
||||||
var playMethod: Int
|
@Persisted var playMethod: Int = PlayMethod.directplay.rawValue
|
||||||
var startedAt: Double?
|
@Persisted var startedAt: Double?
|
||||||
var updatedAt: Double?
|
@Persisted var updatedAt: Double?
|
||||||
var timeListening: Double
|
@Persisted var timeListening: Double = 0
|
||||||
var audioTracks: [AudioTrack]
|
@Persisted var audioTracks = List<AudioTrack>()
|
||||||
var currentTime: Double
|
@Persisted var currentTime: Double = 0
|
||||||
var libraryItem: LibraryItem
|
@Persisted var libraryItem: LibraryItem?
|
||||||
// var localLibraryItem: LocalLibraryItem?
|
@Persisted var localLibraryItem: LocalLibraryItem?
|
||||||
var serverConnectionConfigId: String?
|
@Persisted var serverConnectionConfigId: String?
|
||||||
var serverAddress: String?
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var isActiveSession = true
|
||||||
|
@Persisted var serverUpdatedAt: Double = 0
|
||||||
|
|
||||||
|
var isLocal: Bool { self.localLibraryItem != nil }
|
||||||
|
var mediaPlayer: String { "AVPlayer" }
|
||||||
|
var deviceInfo: [String: String?]? {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
|
||||||
|
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||||
|
ptr in String.init(validatingUTF8: ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"manufacturer": "Apple",
|
||||||
|
"model": modelCode,
|
||||||
|
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var localMediaProgressId: String? {
|
||||||
|
if let localLibraryItem = localLibraryItem, let episodeId = episodeId {
|
||||||
|
return "\(localLibraryItem.id)-\(episodeId)"
|
||||||
|
} else if let localLibraryItem = localLibraryItem {
|
||||||
|
return localLibraryItem.id
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalDuration: Double {
|
||||||
|
var total = 0.0
|
||||||
|
self.audioTracks.forEach { total += $0.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double { self.currentTime / self.totalDuration }
|
||||||
|
|
||||||
|
internal init(id: String, userId: String? = nil, libraryItemId: String? = nil, episodeId: String? = nil, mediaType: String, mediaMetadata: Metadata?, chapters: List<Chapter> = List<Chapter>(), displayTitle: String? = nil, displayAuthor: String? = nil, coverPath: String? = nil, duration: Double, playMethod: Int, startedAt: Double? = nil, updatedAt: Double? = nil, timeListening: Double, audioTracks: List<AudioTrack> = List<AudioTrack>(), currentTime: Double, libraryItem: LibraryItem? = nil, localLibraryItem: LocalLibraryItem? = nil, serverConnectionConfigId: String? = nil, serverAddress: String? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.userId = userId
|
||||||
|
self.libraryItemId = libraryItemId
|
||||||
|
self.episodeId = episodeId
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.mediaMetadata = mediaMetadata
|
||||||
|
self.chapters = chapters
|
||||||
|
self.displayTitle = displayTitle
|
||||||
|
self.displayAuthor = displayAuthor
|
||||||
|
self.coverPath = coverPath
|
||||||
|
self.duration = duration
|
||||||
|
self.playMethod = playMethod
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.timeListening = timeListening
|
||||||
|
self.audioTracks = audioTracks
|
||||||
|
self.currentTime = currentTime
|
||||||
|
self.libraryItem = libraryItem
|
||||||
|
self.localLibraryItem = localLibraryItem
|
||||||
|
self.serverConnectionConfigId = serverConnectionConfigId
|
||||||
|
self.serverAddress = serverAddress
|
||||||
|
self.isActiveSession = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, userId, libraryItemId, episodeId, mediaType, mediaMetadata, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, mediaPlayer, deviceInfo, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
userId = try values.decodeIfPresent(String.self, forKey: .userId)
|
||||||
|
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
|
||||||
|
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||||
|
mediaMetadata = try values.decodeIfPresent(Metadata.self, forKey: .mediaMetadata)
|
||||||
|
if let chapterList = try values.decodeIfPresent([Chapter].self, forKey: .chapters) {
|
||||||
|
chapters.append(objectsIn: chapterList)
|
||||||
|
}
|
||||||
|
displayTitle = try values.decodeIfPresent(String.self, forKey: .displayTitle)
|
||||||
|
displayAuthor = try values.decodeIfPresent(String.self, forKey: .displayAuthor)
|
||||||
|
coverPath = try values.decodeIfPresent(String.self, forKey: .coverPath)
|
||||||
|
duration = try values.decode(Double.self, forKey: .duration)
|
||||||
|
playMethod = try values.decode(Int.self, forKey: .playMethod)
|
||||||
|
startedAt = try values.decodeIfPresent(Double.self, forKey: .startedAt)
|
||||||
|
updatedAt = try values.decodeIfPresent(Double.self, forKey: .updatedAt)
|
||||||
|
timeListening = try values.decode(Double.self, forKey: .timeListening)
|
||||||
|
if let trackList = try values.decodeIfPresent([AudioTrack].self, forKey: .audioTracks) {
|
||||||
|
audioTracks.append(objectsIn: trackList)
|
||||||
|
}
|
||||||
|
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||||
|
libraryItem = try values.decodeIfPresent(LibraryItem.self, forKey: .libraryItem)
|
||||||
|
localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem)
|
||||||
|
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||||
|
isActiveSession = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(userId, forKey: .userId)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(mediaMetadata, forKey: .mediaMetadata)
|
||||||
|
try container.encode(Array(chapters), forKey: .chapters)
|
||||||
|
try container.encode(displayTitle, forKey: .displayTitle)
|
||||||
|
try container.encode(displayAuthor, forKey: .displayAuthor)
|
||||||
|
try container.encode(coverPath, forKey: .coverPath)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(playMethod, forKey: .playMethod)
|
||||||
|
try container.encode(mediaPlayer, forKey: .mediaPlayer)
|
||||||
|
try container.encode(deviceInfo, forKey: .deviceInfo)
|
||||||
|
try container.encode(startedAt, forKey: .startedAt)
|
||||||
|
try container.encode(updatedAt, forKey: .updatedAt)
|
||||||
|
try container.encode(timeListening, forKey: .timeListening)
|
||||||
|
try container.encode(Array(audioTracks), forKey: .audioTracks)
|
||||||
|
try container.encode(currentTime, forKey: .currentTime)
|
||||||
|
try container.encode(libraryItem, forKey: .libraryItem)
|
||||||
|
try container.encode(localLibraryItem, forKey: .localLibraryItem)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(isLocal, forKey: .isLocal)
|
||||||
|
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// PlayerSettings.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/18/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class PlayerSettings: Object {
|
||||||
|
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
|
||||||
|
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
|
||||||
|
@Persisted var playbackRate: Float = 1.0
|
||||||
|
|
||||||
|
// Singleton pattern for Realm objects
|
||||||
|
static func main() -> PlayerSettings {
|
||||||
|
let realm = try! Realm()
|
||||||
|
|
||||||
|
if let settings = realm.objects(PlayerSettings.self).last {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = PlayerSettings()
|
||||||
|
try! realm.write {
|
||||||
|
realm.add(settings)
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// PlayerState.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PlayerState: Codable {
|
||||||
|
case IDLE
|
||||||
|
case BUFFERING
|
||||||
|
case READY
|
||||||
|
case ENDED
|
||||||
|
}
|
||||||
@@ -9,30 +9,28 @@ import Foundation
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class ServerConnectionConfig: Object {
|
class ServerConnectionConfig: Object {
|
||||||
@Persisted(primaryKey: true) var id: String
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
@Persisted(indexed: true) var index: Int
|
@Persisted(indexed: true) var index: Int = 1
|
||||||
@Persisted var name: String
|
@Persisted var name: String = ""
|
||||||
@Persisted var address: String
|
@Persisted var address: String = ""
|
||||||
@Persisted var userId: String
|
@Persisted var userId: String = ""
|
||||||
@Persisted var username: String
|
@Persisted var username: String = ""
|
||||||
@Persisted var token: String
|
@Persisted var token: String = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServerConnectionConfigActiveIndex: Object {
|
class ServerConnectionConfigActiveIndex: Object {
|
||||||
// This could overflow, but you really would have to try
|
// This could overflow, but you really would have to try
|
||||||
@Persisted var index: Int?
|
@Persisted(primaryKey: true) var index: Int?
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
|
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
|
||||||
return Database.realmQueue.sync {
|
return [
|
||||||
return [
|
"id": config.id,
|
||||||
"id": config.id,
|
"name": config.name,
|
||||||
"name": config.name,
|
"index": config.index,
|
||||||
"index": config.index,
|
"address": config.address,
|
||||||
"address": config.address,
|
"userId": config.userId,
|
||||||
"userId": config.userId,
|
"username": config.username,
|
||||||
"username": config.username,
|
"token": config.token,
|
||||||
"token": config.token,
|
]
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// DownloadItem.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class DownloadItem: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String?
|
||||||
|
@Persisted(indexed: true) var libraryItemId: String?
|
||||||
|
@Persisted var episodeId: String?
|
||||||
|
@Persisted var userMediaProgress: MediaProgress?
|
||||||
|
@Persisted var serverConnectionConfigId: String?
|
||||||
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var serverUserId: String?
|
||||||
|
@Persisted var mediaType: String?
|
||||||
|
@Persisted var itemTitle: String?
|
||||||
|
@Persisted var media: MediaType?
|
||||||
|
@Persisted var downloadItemParts = List<DownloadItemPart>()
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, libraryItemId, episodeId, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try? values.decode(String.self, forKey: .id)
|
||||||
|
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||||
|
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
|
||||||
|
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
|
||||||
|
mediaType = try? values.decode(String.self, forKey: .mediaType)
|
||||||
|
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||||
|
if let parts = try? values.decode([DownloadItemPart].self, forKey: .downloadItemParts) {
|
||||||
|
downloadItemParts.append(objectsIn: parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(serverUserId, forKey: .serverUserId)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(itemTitle, forKey: .itemTitle)
|
||||||
|
try container.encode(Array(downloadItemParts), forKey: .downloadItemParts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DownloadItem {
|
||||||
|
convenience init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = libraryItem.id
|
||||||
|
self.libraryItemId = libraryItem.id
|
||||||
|
self.userMediaProgress = libraryItem.userMediaProgress
|
||||||
|
self.serverConnectionConfigId = server.id
|
||||||
|
self.serverAddress = server.address
|
||||||
|
self.serverUserId = server.userId
|
||||||
|
self.mediaType = libraryItem.mediaType
|
||||||
|
self.itemTitle = libraryItem.media?.metadata?.title
|
||||||
|
self.media = libraryItem.media
|
||||||
|
|
||||||
|
if let episodeId = episodeId {
|
||||||
|
self.id! += "-\(episodeId)"
|
||||||
|
self.episodeId = episodeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDoneDownloading() -> Bool {
|
||||||
|
self.downloadItemParts.allSatisfy({ $0.completed })
|
||||||
|
}
|
||||||
|
|
||||||
|
func didDownloadSuccessfully() -> Bool {
|
||||||
|
self.downloadItemParts.allSatisfy({ $0.failed == false })
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.realm?.delete(self.downloadItemParts)
|
||||||
|
self.realm?.delete(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// DownloadItemPart.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class DownloadItemPart: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id = ""
|
||||||
|
@Persisted var filename: String?
|
||||||
|
@Persisted var itemTitle: String?
|
||||||
|
@Persisted var serverPath: String?
|
||||||
|
@Persisted var audioTrack: AudioTrack?
|
||||||
|
@Persisted var episode: PodcastEpisode?
|
||||||
|
@Persisted var completed: Bool = false
|
||||||
|
@Persisted var moved: Bool = false
|
||||||
|
@Persisted var failed: Bool = false
|
||||||
|
@Persisted var uri: String?
|
||||||
|
@Persisted var destinationUri: String?
|
||||||
|
@Persisted var progress: Double = 0
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, filename, itemTitle, completed, moved, failed, progress
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
filename = try? values.decode(String.self, forKey: .filename)
|
||||||
|
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||||
|
completed = try values.decode(Bool.self, forKey: .completed)
|
||||||
|
moved = try values.decode(Bool.self, forKey: .moved)
|
||||||
|
failed = try values.decode(Bool.self, forKey: .failed)
|
||||||
|
progress = try values.decode(Double.self, forKey: .progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(filename, forKey: .filename)
|
||||||
|
try container.encode(itemTitle, forKey: .itemTitle)
|
||||||
|
try container.encode(completed, forKey: .completed)
|
||||||
|
try container.encode(moved, forKey: .moved)
|
||||||
|
try container.encode(failed, forKey: .failed)
|
||||||
|
try container.encode(progress, forKey: .progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DownloadItemPart {
|
||||||
|
convenience init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = destination.toBase64()
|
||||||
|
self.filename = filename
|
||||||
|
self.itemTitle = itemTitle
|
||||||
|
self.serverPath = serverPath
|
||||||
|
self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
|
||||||
|
self.episode = PodcastEpisode.detachCopy(of: episode)
|
||||||
|
|
||||||
|
let config = Store.serverConfig!
|
||||||
|
var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)"
|
||||||
|
if (serverPath.hasSuffix("/cover")) {
|
||||||
|
downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||||
|
}
|
||||||
|
self.uri = downloadUrl
|
||||||
|
self.destinationUri = destination
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadURL: URL? {
|
||||||
|
if let uri = self.uri {
|
||||||
|
return URL(string: uri)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinationURL: URL? {
|
||||||
|
if let destinationUri = self.destinationUri {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: destinationUri)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeType() -> String? {
|
||||||
|
audioTrack?.mimeType ?? episode?.audioTrack?.mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// LocalFile.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalFile: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
|
@Persisted var filename: String?
|
||||||
|
@Persisted var _contentUrl: String = ""
|
||||||
|
@Persisted var mimeType: String?
|
||||||
|
@Persisted var size: Int = 0
|
||||||
|
|
||||||
|
var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString }
|
||||||
|
var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! }
|
||||||
|
var basePath: String? { self.filename }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, filename, contentUrl, mimeType, size, basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
filename = try? values.decode(String.self, forKey: .filename)
|
||||||
|
mimeType = try? values.decode(String.self, forKey: .mimeType)
|
||||||
|
size = try values.decode(Int.self, forKey: .size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(filename, forKey: .filename)
|
||||||
|
try container.encode(contentUrl, forKey: .contentUrl)
|
||||||
|
try container.encode(mimeType, forKey: .mimeType)
|
||||||
|
try container.encode(size, forKey: .size)
|
||||||
|
try container.encode(basePath, forKey: .basePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalFile {
|
||||||
|
convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = "\(libraryItemId)_\(filename.toBase64())"
|
||||||
|
self.filename = filename
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self._contentUrl = localUrl
|
||||||
|
self.size = fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var absolutePath: String {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: self._contentUrl)?.absoluteString ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAudioFile() -> Bool {
|
||||||
|
switch self.mimeType {
|
||||||
|
case "application/octet-stream",
|
||||||
|
"video/mp4":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return self.mimeType?.starts(with: "audio") ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||