Compare commits

...
Author SHA1 Message Date
advplyr 910b3a2a17 Version bump 0.9.48-beta 2022-06-05 09:55:22 -05:00
advplyr 916da91ccb Fix:Android auto check if server connection available and use first available server config, add ping api check with 3 second timeout #212 #167 2022-06-05 09:51:37 -05:00
advplyr d70a99254a Android version code bump 2022-06-04 17:20:14 -05:00
advplyr dcf5bb61a2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-06-04 17:12:14 -05:00
advplyr aa65bb10c1 Update:Android auto reset saved data if server config id changes 2022-06-04 17:12:10 -05:00
advplyr 158448f8a2 iOS version bump 0.9.47 2022-06-04 16:37:26 -05:00
advplyr f4be9b3e26 Update:Pass device info with play request 2022-06-04 16:36:49 -05:00
advplyr 15d68ca285 Version bump 0.9.47-beta 2022-06-04 15:11:49 -05:00
advplyr ad12e6a19d Add:Author, narrator and series links on item page #186 2022-06-04 10:19:31 -05:00
advplyr 1f1b2fe85a Add:Filter for podcast episodes 2022-06-03 19:46:43 -05:00
advplyr 480df58ce4 Fix:Check with server after pause of 1 minute or longer for updated media progress & show toast on client if progress sync is failing 2022-06-03 18:58:07 -05:00
advplyr 2decf532b2 Add:Not Finished progress filter 2022-06-02 18:20:52 -05:00
advplyr 3ba87419ae Update:Hide size on item landing page if not set 2022-06-02 18:14:29 -05:00
advplyr 1c78af37fa Update:Item page redesign with more details like narrator and chapters, decreasing cover size 2022-06-02 17:57:37 -05:00
advplyr 2b5373aedd Update:Rename Hard Delete 2022-06-02 17:02:30 -05:00
advplyr 99bf960b8a Add:Filter and sort for podcast episodes table, Update:Sync local media progress when media progress is updated on the server 2022-06-01 19:38:26 -05:00
advplyr c4aca22c28 Fix:Android auto reset server items if no longer connected to server #201 2022-05-29 18:43:53 -05:00
advplyr 58bd0e0cee Add:Click and drag player progress track #110 2022-05-29 18:13:25 -05:00
advplyr c1c56f8f52 Fix play request payload 2022-05-27 20:48:13 -05:00
advplyr f930ba1941 Update:Send android device info when opening playback sessions 2022-05-26 19:10:29 -05:00
advplyr 251116a5ce Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-05-24 09:08:28 -05:00
advplyr 06739c0401 Fix:Base64 encoding include URL_SAFE flag to prevent having / in local library item ids #192 2022-05-24 09:08:23 -05:00
advplyr 92f069d1e6 iOS version bump 0.9.46 2022-05-22 17:51:35 -05:00
advplyr 62c5042ecc Fix:Cover image stretch to fill 2022-05-22 17:49:10 -05:00
advplyr 604b086c0b Android version bump 0.9.46-beta 2022-05-22 17:04:19 -05:00
advplyr 0e0b356f6b Fix:Reset volume when playing from notification after sleep timer decreases volume #182 2022-05-22 16:24:38 -05:00
advplyr 573768e2b2 Fix:Select podcast folder when none is set yet 2022-05-22 15:56:32 -05:00
advplyr 236fd09c94 Fix:Hide download button if user does not have download permission #189 2022-05-22 15:49:42 -05:00
advplyr cb6cb5f637 Fix:M4B downloads on Android 9 & 10 not detected as audio files (mimeType is showing as application/octet-stream) #154 2022-05-22 15:31:25 -05:00
advplyr eb916a3ab0 Add:Duration sort 2022-05-22 14:47:07 -05:00
advplyr 61b8ca1510 Add:Persist scroll position for bookshelves #196, Fix:Hide clear filter button for series and collections 2022-05-22 14:19:13 -05:00
advplyr 5d2da97dc5 Update:Android auto to show libraries and browseable podcasts, Fix:Download podcast set episodes and cover art correctly 2022-05-22 13:40:01 -05:00
advplyr d626686614 Add start of downloading library item on iOS 2022-05-14 11:50:53 -05:00
advplyr 60ee33cb72 Update:Show download button on iOS and call downloadLibraryItem without a folderId 2022-05-14 09:08:52 -05:00
advplyr 8d2498e96d Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-05-13 16:27:10 -05:00
advplyr 1794484bed Fix:Realtime updates for book cards when series sequence changes 2022-05-13 16:27:05 -05:00
advplyr f2b6331843 Add iOS AbsDownloader and AbsFileSystem plugins 2022-05-13 15:18:21 -05:00
advplyr 8d5f33245f Version bump 0.9.45 2022-05-13 13:53:37 -05:00
advplyr 1ee544b842 Fix:LazyBookshelf default to square cover before server settings are loaded 2022-05-13 13:42:38 -05:00
advplyr 0fd9463c7c Update:Set installLocation in android manifest to preferExternal for amazon app store 2022-05-13 11:04:52 -05:00
advplyr ad5edf3aee Fix:iOS audio player cutting off timestamps #173 2022-05-13 09:28:43 -05:00
advplyr d5fafd8cab Fix:Bookshelf list view wrong entity height #179 2022-05-12 18:24:36 -05:00
advplyr 92256f2fed Fix:Create bookmark was requiring a socket connection still 2022-05-11 18:59:24 -05:00
advplyr 5fa8d4c989 Fix:Check local library item is linked to a server item before sending playback session progress sync to server 2022-05-11 17:05:51 -05:00
advplyr 6b75f79f00 Fix wrong android version in comment 2022-05-08 15:01:36 -05:00
advplyr b0c9f29d90 Fix:Downloaded covers when used in media notification crashing android 11 #35 #164 2022-05-08 14:34:28 -05:00
advplyr 7721afc116 Remove unnecessary background-image 2022-05-07 20:19:49 -05:00
advplyr bc85e9bbfa Merge 2022-05-07 17:33:07 -05:00
advplyr e8b6602fe5 Remove app update package 2022-05-07 17:32:18 -05:00
advplyr 0c49bcfe3f iOS version bump 0.9.44 2022-05-06 20:04:42 -05:00
advplyr aecb6a8dd2 Android version bump 0.9.44-beta 2022-05-06 20:01:01 -05:00
advplyr 718947522e Fix:Remove download notifications after download item finished #161 2022-05-06 19:45:07 -05:00
advplyr 2f8ca51447 Add:Vibrate when sleep timer is extended or reset by shaking #169 2022-05-06 19:28:07 -05:00
advplyr 4fa6dd2616 Fix:Cover crashing when using in media notification. Convert content uri for local image to File and pass main activity context instead of player service context #35 #164 2022-05-06 18:57:05 -05:00
advplyr 736e57fafd Add:Fallback to transcode when direct play fails, and send playback failed event to client 2022-05-06 18:17:45 -05:00
advplyr 30d86279a5 Update:Host fonts locally 2022-05-05 18:25:32 -05:00
advplyrandGitHub 8bbfcdeb82 Merge pull request #166 from rasmuslos/master
Added sleep timer
2022-05-05 07:49:44 -05:00
Rasmus Krämer d23cf62264 Fix sleep timer increase & decrease 2022-05-05 14:38:34 +02:00
advplyr 73d5b19d2b Update:Casting when playing local item if connected to server linked to local item #170, Update:Play button color when playing local item verse server item, Add:Show play method at the top of audio player (Direct, Local, Transcode) 2022-05-04 19:31:56 -05:00
Rasmus Krämer 1959351125 Removed unnecessary comment 2022-05-03 15:03:53 +02:00
Rasmus Krämer 0708133779 Fixed crash 2022-05-03 15:01:30 +02:00
Rasmus Krämer 9a81fc3688 Merge branch 'advplyr-master' 2022-05-03 14:42:20 +02:00
Rasmus Krämer ac71d39265 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app into advplyr-master 2022-05-03 14:41:46 +02:00
Rasmus Krämer 4203654ec8 Added sleep timer 2022-05-03 14:32:46 +02:00
Rasmus Krämer 9701c767b2 Small improvements 2022-05-03 12:55:13 +02:00
advplyrandGitHub 0223df4f9e Merge pull request #162 from benonymity/ListViewImprovements
Tweaks for bookshelf list view
2022-05-01 18:34:48 -05:00
benonymity 7549385404 Return null if not podcast 2022-05-01 18:27:02 -04:00
benonymity 3f2d0ed8b1 Display number of podcast episodes in list view 2022-05-01 18:03:37 -04:00
benonymity e4a5927e07 Tweaks for bookshelf list view 2022-05-01 16:49:56 -04:00
advplyr a3aac4da75 Update: iOS AudioPlayer direct play with multi-track using AVQueuePlayer 2022-05-01 12:19:31 -05:00
advplyr 1e9453e501 Fix:Check google services are available before initializing chromecast #158 2022-04-30 16:33:41 -05:00
advplyr bec3f5841e Fix:Setting local library item as finished when local media progress does not exist 2022-04-30 15:35:20 -05:00
advplyrandGitHub 068762912f Merge pull request #134 from benonymity/MarkAsRead
Add mark as read button
2022-04-30 14:58:45 -05:00
advplyr 01e85d0e91 Fix:Android replace reserved characters in filenames #154 2022-04-30 14:45:55 -05:00
benonymity a67c19f30f Shorten play button text 2022-04-30 15:01:27 -04:00
Rasmus Krämer 394363c8cb Send report when playback ends 2022-04-30 10:58:08 +02:00
benonymity 7fd51ebcc1 Fix button padding, final tests 2022-04-28 20:05:25 -04:00
advplyr 15b7fbce47 iOS version bump 0.9.43 2022-04-28 18:06:13 -05:00
advplyr e03f878865 iOS fix: Logging out when player is open crashing because server config is nil, added nil check in Api requests 2022-04-28 18:05:33 -05:00
advplyr cffa7f5344 Web and android version bump 0.9.43-beta 2022-04-28 17:44:44 -05:00
advplyr 8411428241 Fix:Auto rewind and jump forward/jump backward on multi-tracks #143 2022-04-28 17:19:02 -05:00
benonymity ae4678cf24 Resize button, no podcast downloads on iOS 2022-04-28 17:13:21 -04:00
benonymity 68e565ebe2 Don't show if a podcast 2022-04-28 16:38:27 -04:00
benonymity d99f4406b7 Removed episode reference, smaller play button 2022-04-28 13:21:47 -04:00
BenandGitHub 2064cd8380 Merge branch 'advplyr:master' into MarkAsRead 2022-04-28 12:24:44 -04:00
benonymity 415ff65561 Display when local, add CSS classes 2022-04-27 15:19:29 -04:00
advplyr be885009ad Change:persist last search term #142 2022-04-26 17:05:49 -05:00
advplyr d64dd63ea4 Fix:Close playback when logging out 2022-04-26 16:46:29 -05:00
advplyrandGitHub 72f10e5f42 Merge pull request #148 from selfhost-alt/fix-local-scanning-android
Handle audio probes that don't return any tags
2022-04-26 16:13:04 -05:00
Selfhost Alt 511386e80a Handle audio probes that don't return any tags 2022-04-26 10:32:14 -07:00
advplyr b219756cb7 Fix:Save local media progress when downloading library item by requesting user media progress with item from server (will require server update) #145 2022-04-25 19:01:20 -05:00
advplyr 31c753ffcc Fix:Downloading cover art query strings to set token and jpeg format #144 2022-04-25 17:30:59 -05:00
advplyr 2dd822e04d Fix: Sleep timer for multi-track playback sessions - use duration of all tracks stored in playback session instead of duration from exoplayer #140 2022-04-25 17:22:12 -05:00
advplyrandGitHub d18972e2f3 Merge pull request #132 from benonymity/DataClasses
New Data Classes
2022-04-25 07:27:22 -05:00
benonymity 81ca757c77 Book-Podcast combination, optional fixes 2022-04-25 00:15:44 -04:00
BenandGitHub 7189588c2b Merge branch 'advplyr:master' into DataClasses 2022-04-24 22:47:48 -04:00
benonymity 1fed00ca81 Add localLIbraryProgress updates 2022-04-24 22:02:22 -04:00
BenandGitHub a63022a669 Merge branch 'advplyr:master' into MarkAsRead 2022-04-24 21:40:01 -04:00
advplyr 843f6ca606 Update home page for displaying podcast episode shelves 2022-04-24 14:15:37 -05:00
advplyr 52fd8ac5e8 Fix crash when probing audio files by updating start/end chapter data type to Long #128, remove unused files and generic database save/load methods 2022-04-24 13:28:14 -05:00
advplyrandGitHub d5b69be7c1 Merge pull request #126 from benonymity/SwipeBack
Enable swipe gesture to go back
2022-04-23 15:57:28 -05:00
advplyr 729e959506 iOS fix crash when playing item if player is already open, persist playback rate between playbacks, version bump 0.9.42 2022-04-23 15:40:18 -05:00
advplyr a0a7fd1e14 Andriod version bump 0.9.42-beta 2022-04-23 15:36:21 -05:00
advplyr 0b4c999018 Android persist playback rate #138 2022-04-23 15:06:51 -05:00
advplyr c5a9677ac6 Fix cover aspect ratio #136 2022-04-23 14:38:29 -05:00
advplyr dbe0b7ea69 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-04-23 14:20:31 -05:00
advplyr 32df099e8a Fix bookmarks and hide for local items #137 2022-04-23 14:19:56 -05:00
advplyrandGitHub 612c48174c Merge pull request #135 from benonymity/LoadingLibrary
Loading indicator while awaiting library
2022-04-23 13:51:18 -05:00
advplyr e8e7a241c6 Android fix downloader to first download to temp directory then move files to final destination folder 2022-04-23 13:40:55 -05:00
benonymity 9263028c56 Loading indicator while awaiting library 2022-04-23 00:56:46 -04:00
benonymity 114dbd24bc Add button to mark book as read 2022-04-23 00:27:03 -04:00
benonymity 4d0d1eb88f New Data Classes 2022-04-22 20:31:45 -04:00
advplyr b72aba36b8 Fix android download complete crash removing unnecessary intent #128, fix author image path #131 2022-04-22 18:21:27 -05:00
advplyr 9d6a467517 Fix local audio playback issue, update ffmpeg-kit to min version 2022-04-21 18:50:01 -05:00
BenandGitHub 49ad1ef601 Merge branch 'advplyr:master' into SwipeBack 2022-04-20 17:25:46 -04:00
benonymity a17b588e29 Hacky SwipeBack 2022-04-20 17:25:12 -04:00
109 changed files with 22507 additions and 1266 deletions
+3 -3
View File
@@ -29,8 +29,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 69 versionCode 78
versionName "0.9.41-beta" versionName "0.9.48-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.
@@ -120,7 +120,7 @@ dependencies {
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
// FFMPEG-Kit // FFMPEG-Kit
implementation 'com.arthenica:ffmpeg-kit-full:4.5.1' implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
} }
apply from: 'capacitor.build.gradle' apply from: 'capacitor.build.gradle'
-1
View File
@@ -15,7 +15,6 @@ dependencies {
implementation project(':capacitor-network') implementation project(':capacitor-network')
implementation project(':capacitor-status-bar') implementation project(':capacitor-status-bar')
implementation project(':capacitor-storage') implementation project(':capacitor-storage')
implementation project(':robingenz-capacitor-app-update')
} }
+1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution" xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:installLocation="preferExternal"
package="com.audiobookshelf.app"> package="com.audiobookshelf.app">
<!-- Permissions --> <!-- Permissions -->
@@ -22,9 +22,5 @@
{ {
"pkg": "@capacitor/storage", "pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin" "classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
},
{
"pkg": "@robingenz/capacitor-app-update",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
} }
] ]
@@ -26,7 +26,6 @@ class MainActivity : BridgeActivity() {
private lateinit var mConnection : ServiceConnection private lateinit var mConnection : ServiceConnection
lateinit var pluginCallback : () -> Unit lateinit var pluginCallback : () -> Unit
lateinit var downloaderCallback : (String, Long) -> Unit
val storageHelper = SimpleStorageHelper(this) val storageHelper = SimpleStorageHelper(this)
val storage = SimpleStorage(this) val storage = SimpleStorage(this)
@@ -36,21 +35,6 @@ class MainActivity : BridgeActivity() {
Manifest.permission.READ_EXTERNAL_STORAGE Manifest.permission.READ_EXTERNAL_STORAGE
) )
val broadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("complete", thisdlid)
}
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("clicked", thisdlid)
}
}
}
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
// TODO: Optimize using strict mode logs // TODO: Optimize using strict mode logs
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() // StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
@@ -85,17 +69,11 @@ class MainActivity : BridgeActivity() {
registerPlugin(AbsFileSystem::class.java) registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java) registerPlugin(AbsDatabase::class.java)
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
}
registerReceiver(broadcastReceiver, filter)
Paper.init(applicationContext) Paper.init(applicationContext)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
unregisterReceiver(broadcastReceiver)
} }
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -138,10 +116,6 @@ class MainActivity : BridgeActivity() {
stopService(stopIntent) stopService(stopIntent)
} }
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
downloaderCallback = cb
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState) storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
@@ -2,11 +2,6 @@ package com.audiobookshelf.app.data
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.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.jsonschema.JsonSerializableSchema
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeStream( data class AudioProbeStream(
@@ -27,15 +22,15 @@ data class AudioProbeChapterTags(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeChapter( data class AudioProbeChapter(
val id:Int, val id:Int,
val start:Int, val start:Long,
val end:Int, val end:Long,
val tags:AudioProbeChapterTags? val tags:AudioProbeChapterTags?
) { ) {
@JsonIgnore @JsonIgnore
fun getBookChapter():BookChapter { fun getBookChapter():BookChapter {
var startS = start / 1000.0 val startS = start / 1000.0
var endS = end / 1000.0 val endS = end / 1000.0
var title = tags?.title ?: "Chapter $id" val title = tags?.title ?: "Chapter $id"
return BookChapter(id, startS, endS, title) return BookChapter(id, startS, endS, title)
} }
} }
@@ -57,7 +52,7 @@ data class AudioProbeFormat(
val duration:Double, val duration:Double,
val size:Long, val size:Long,
val bit_rate:Double, val bit_rate:Double,
val tags:AudioProbeFormatTags val tags:AudioProbeFormatTags?
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -68,8 +63,8 @@ class AudioProbeResult (
val duration get() = format.duration val duration get() = format.duration
val size get() = format.size val size get() = format.size
val title get() = format.tags.title ?: format.filename.split("/").last() val title get() = format.tags?.title ?: format.filename.split("/").last()
val artist get() = format.tags.artist ?: "" val artist get() = format.tags?.artist ?: ""
@JsonIgnore @JsonIgnore
fun getBookChapters(): List<BookChapter> { fun getBookChapters(): List<BookChapter> {
@@ -25,7 +25,8 @@ data class LibraryItem(
var isInvalid:Boolean, var isInvalid:Boolean,
var mediaType:String, var mediaType:String,
var media:MediaType, var media:MediaType,
var libraryFiles:MutableList<LibraryFile>? var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper() { ) : LibraryItemWrapper() {
@get:JsonIgnore @get:JsonIgnore
val title get() = media.metadata.title val title get() = media.metadata.title
@@ -41,6 +42,15 @@ data class LibraryItem(
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") 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 @JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat { fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply { return MediaMetadataCompat.Builder().apply {
@@ -73,6 +83,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
open fun removeAudioTrack(localFileId:String) { } open fun removeAudioTrack(localFileId:String) { }
@JsonIgnore @JsonIgnore
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) } open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -81,11 +92,12 @@ class Podcast(
coverPath:String?, coverPath:String?,
var tags:MutableList<String>, var tags:MutableList<String>,
var episodes:MutableList<PodcastEpisode>?, var episodes:MutableList<PodcastEpisode>?,
var autoDownloadEpisodes:Boolean var autoDownloadEpisodes:Boolean,
var numEpisodes:Int?
) : MediaType(metadata, coverPath) { ) : MediaType(metadata, coverPath) {
@JsonIgnore @JsonIgnore
override fun getAudioTracks():List<AudioTrack> { override fun getAudioTracks():List<AudioTrack> {
var tracks = episodes?.map { it.audioTrack } val tracks = episodes?.map { it.audioTrack }
return tracks?.filterNotNull() ?: mutableListOf() return tracks?.filterNotNull() ?: mutableListOf()
} }
@JsonIgnore @JsonIgnore
@@ -98,7 +110,7 @@ class Podcast(
// Add new episodes // Add new episodes
audioTracks.forEach { at -> audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null) val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
} }
} }
@@ -111,7 +123,7 @@ class Podcast(
} }
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { override fun addAudioTrack(audioTrack:AudioTrack) {
var 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
@@ -131,8 +143,8 @@ class Podcast(
} }
} }
@JsonIgnore @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode) { fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
var 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
@@ -140,12 +152,13 @@ class Podcast(
it.index = index it.index = index
index++ index++
} }
return newEpisode
} }
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes // Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Podcast { override fun getLocalCopy(): Podcast {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes) return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
} }
} }
@@ -158,7 +171,8 @@ class Book(
var chapters:List<BookChapter>?, var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?, var tracks:MutableList<AudioTrack>?,
var size:Long?, var size:Long?,
var duration:Double? var duration:Double?,
var numTracks:Int?
) : MediaType(metadata, coverPath) { ) : MediaType(metadata, coverPath) {
@JsonIgnore @JsonIgnore
override fun getAudioTracks():List<AudioTrack> { override fun getAudioTracks():List<AudioTrack> {
@@ -207,7 +221,7 @@ class Book(
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Book { override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null) return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
} }
} }
@@ -279,7 +293,29 @@ data class PodcastEpisode(
var duration:Double?, var duration:Double?,
var size:Long?, var size:Long?,
var serverEpisodeId:String? // For local podcasts to match with server podcasts var serverEpisodeId:String? // For local podcasts to match with server podcasts
) ) {
@JsonIgnore
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat {
var coverUri:Uri = Uri.EMPTY
val podcast = if(libraryItem is LocalLibraryItem) {
coverUri = libraryItem.getCoverUri()
libraryItem.media as Podcast
} else {
coverUri = (libraryItem as LibraryItem).getCoverUri()
(libraryItem as LibraryItem).media as Podcast
}
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, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
}.build()
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryFile( data class LibraryFile(
@@ -310,7 +346,16 @@ data class Library(
var folders:MutableList<Folder>, var folders:MutableList<Folder>,
var icon:String, var icon:String,
var mediaType:String var mediaType:String
) ) {
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
}.build()
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class Folder( data class Folder(
@@ -355,3 +400,23 @@ data class BookChapter(
var end:Double, var end:Double,
var title:String? var title:String?
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
var progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
)
// Helper class
data class LibraryItemWithEpisode(
var libraryItemWrapper:LibraryItemWrapper,
var episode:PodcastEpisode
)
@@ -17,9 +17,9 @@ class DbManager {
} }
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> { fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
var localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf() val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
Paper.book("localLibraryItems").allKeys.forEach { Paper.book("localLibraryItems").allKeys.forEach {
var localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it) val localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) { if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
localLibraryItems.add(localLibraryItem) localLibraryItems.add(localLibraryItem)
} }
@@ -28,7 +28,7 @@ class DbManager {
} }
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> { fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
var localLibraryItems = getLocalLibraryItems() val localLibraryItems = getLocalLibraryItems()
return localLibraryItems.filter { return localLibraryItems.filter {
it.folderId == folderId it.folderId == folderId
} }
@@ -42,6 +42,20 @@ class DbManager {
return Paper.book("localLibraryItems").read(localLibraryItemId) return Paper.book("localLibraryItems").read(localLibraryItemId)
} }
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
var podcastEpisode:PodcastEpisode? = null
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
podcastEpisode != null
}
return if (localLibraryItem != null) {
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
} else {
null
}
}
fun removeLocalLibraryItem(localLibraryItemId:String) { fun removeLocalLibraryItem(localLibraryItemId:String) {
Paper.book("localLibraryItems").delete(localLibraryItemId) Paper.book("localLibraryItems").delete(localLibraryItemId)
} }
@@ -65,7 +79,7 @@ class DbManager {
} }
fun getAllLocalFolders():List<LocalFolder> { fun getAllLocalFolders():List<LocalFolder> {
var localFolders:MutableList<LocalFolder> = mutableListOf() val localFolders:MutableList<LocalFolder> = mutableListOf()
Paper.book("localFolders").allKeys.forEach { localFolderId -> Paper.book("localFolders").allKeys.forEach { localFolderId ->
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let { Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
localFolders.add(it) localFolders.add(it)
@@ -75,7 +89,7 @@ class DbManager {
} }
fun removeLocalFolder(folderId:String) { fun removeLocalFolder(folderId:String) {
var localLibraryItems = getLocalLibraryItemsInFolder(folderId) val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
localLibraryItems.forEach { localLibraryItems.forEach {
Paper.book("localLibraryItems").delete(it.id) Paper.book("localLibraryItems").delete(it.id)
} }
@@ -91,7 +105,7 @@ class DbManager {
} }
fun getDownloadItems():List<AbsDownloader.DownloadItem> { fun getDownloadItems():List<AbsDownloader.DownloadItem> {
var downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf() val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
Paper.book("downloadItems").allKeys.forEach { downloadItemId -> Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let { Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
downloadItems.add(it) downloadItems.add(it)
@@ -108,7 +122,7 @@ class DbManager {
return Paper.book("localMediaProgress").read(localMediaProgressId) return Paper.book("localMediaProgress").read(localMediaProgressId)
} }
fun getAllLocalMediaProgress():List<LocalMediaProgress> { fun getAllLocalMediaProgress():List<LocalMediaProgress> {
var mediaProgress:MutableList<LocalMediaProgress> = mutableListOf() val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId -> Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let { Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
mediaProgress.add(it) mediaProgress.add(it)
@@ -126,14 +140,14 @@ class DbManager {
// Make sure all local file ids still exist // Make sure all local file ids still exist
fun cleanLocalLibraryItems() { fun cleanLocalLibraryItems() {
var localLibraryItems = getLocalLibraryItems() val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli -> localLibraryItems.forEach { lli ->
var hasUpates = false var hasUpates = false
// Check local files // Check local files
lli.localFiles = lli.localFiles.filter { localFile -> lli.localFiles = lli.localFiles.filter { localFile ->
var file = File(localFile.absolutePath) val file = File(localFile.absolutePath)
if (!file.exists()) { if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}") Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
hasUpates = true hasUpates = true
@@ -143,7 +157,7 @@ class DbManager {
// Check audio tracks and episodes // Check audio tracks and episodes
if (lli.isPodcast) { if (lli.isPodcast) {
var podcast = lli.media as Podcast val podcast = lli.media as Podcast
podcast.episodes = podcast.episodes?.filter { ep -> podcast.episodes = podcast.episodes?.filter { ep ->
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) { if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}") Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
@@ -152,7 +166,7 @@ class DbManager {
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
} as MutableList<PodcastEpisode> } as MutableList<PodcastEpisode>
} else { } else {
var book = lli.media as Book val book = lli.media as Book
book.tracks = book.tracks?.filter { track -> book.tracks = book.tracks?.filter { track ->
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) { if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}") Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
@@ -164,7 +178,7 @@ class DbManager {
// Check cover still there // Check cover still there
lli.coverAbsolutePath?.let { lli.coverAbsolutePath?.let {
var coverFile = File(it) val coverFile = File(it)
if (!coverFile.exists()) { if (!coverFile.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}") Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
@@ -183,10 +197,10 @@ class DbManager {
// Remove any local media progress where the local media item is not found // Remove any local media progress where the local media item is not found
fun cleanLocalMediaProgress() { fun cleanLocalMediaProgress() {
var localMediaProgress = getAllLocalMediaProgress() val localMediaProgress = getAllLocalMediaProgress()
var localLibraryItems = getLocalLibraryItems() val localLibraryItems = getLocalLibraryItems()
localMediaProgress.forEach { localMediaProgress.forEach {
var matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId } val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
if (matchingLLI == null) { if (matchingLLI == null) {
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing") Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
Paper.book("localMediaProgress").delete(it.id) Paper.book("localMediaProgress").delete(it.id)
@@ -195,8 +209,8 @@ class DbManager {
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing") Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing")
Paper.book("localMediaProgress").delete(it.id) Paper.book("localMediaProgress").delete(it.id)
} else { } else {
var podcast = matchingLLI.media as Podcast val podcast = matchingLLI.media as Podcast
var matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId } val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
if (matchingLEp == null) { if (matchingLEp == null) {
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing") Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
Paper.book("localMediaProgress").delete(it.id) Paper.book("localMediaProgress").delete(it.id)
@@ -212,15 +226,4 @@ class DbManager {
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? { fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
return Paper.book("localPlaybackSession").read(playbackSessionId) return Paper.book("localPlaybackSession").read(playbackSessionId)
} }
fun saveObject(db:String, key:String, value:JSONObject) {
Log.d(tag, "Saving Object $key ${value.toString()}")
Paper.book(db).write(key, value)
}
fun loadObject(db:String, key:String):JSONObject? {
var json: JSONObject? = Paper.book(db).read(key)
Log.d(tag, "Loaded Object $key $json")
return json
}
} }
@@ -42,6 +42,8 @@ data class LocalFile(
) { ) {
@JsonIgnore @JsonIgnore
fun isAudioFile():Boolean { fun isAudioFile():Boolean {
if (mimeType == "application/octet-stream") return true
if (mimeType == "video/mp4") return true
return mimeType?.startsWith("audio") == true return mimeType?.startsWith("audio") == true
} }
} }
@@ -64,3 +66,20 @@ data class LocalFolder(
JsonSubTypes.Type(LocalLibraryItem::class) JsonSubTypes.Type(LocalLibraryItem::class)
) )
open class LibraryItemWrapper() open class LibraryItemWrapper()
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo(
var manufacturer:String,
var model:String,
var brand:String,
var sdkVersion:Int,
var clientVersion: String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PlayItemRequestPayload(
var mediaPlayer:String,
var forceDirectPlay:Boolean,
var forceTranscode:Boolean,
var deviceInfo:DeviceInfo
)
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
@@ -44,15 +45,15 @@ data class LocalLibraryItem(
@JsonIgnore @JsonIgnore
fun getDuration():Double { fun getDuration():Double {
var total = 0.0 var total = 0.0
var audioTracks = media.getAudioTracks() val audioTracks = media.getAudioTracks()
audioTracks.forEach{ total += it.duration } audioTracks.forEach{ total += it.duration }
return total return total
} }
@JsonIgnore @JsonIgnore
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) { fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
media.setAudioTracks(audioTracks)
localFiles = _localFiles localFiles = _localFiles
media.setAudioTracks(audioTracks)
if (coverContentUrl != null) { if (coverContentUrl != null) {
if (localFiles.find { it.contentUrl == coverContentUrl } == null) { if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
@@ -66,24 +67,25 @@ data class LocalLibraryItem(
@JsonIgnore @JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession { fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
var localEpisodeId = episode?.id val localEpisodeId = episode?.id
var sessionId = "play_local_${UUID.randomUUID()}" val sessionId = "play_local_${UUID.randomUUID()}"
// Get current progress for local media
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId" val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId) val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
var currentTime = mediaProgress?.currentTime ?: 0.0 val currentTime = mediaProgress?.currentTime ?: 0.0
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor // TODO: Clean up add mediaType methods for displayTitle and displayAuthor
var mediaMetadata = media.metadata val mediaMetadata = media.metadata
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf() val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack> var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
var authorName = mediaMetadata.getAuthorDisplayName() val authorName = mediaMetadata.getAuthorDisplayName()
if (episode != null) { // Get podcast episode audio track if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}") Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
} }
var dateNow = System.currentTimeMillis() val dateNow = System.currentTimeMillis()
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player") return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
} }
@@ -93,15 +95,17 @@ data class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat { fun getMediaMetadata(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri()
return MediaMetadataCompat.Builder().apply { return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString()) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString()) putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build() }.build()
} }
@@ -58,13 +58,13 @@ data class LocalMediaItem(
@JsonIgnore @JsonIgnore
fun getLocalLibraryItem():LocalLibraryItem { fun getLocalLibraryItem():LocalLibraryItem {
var mediaMetadata = getMediaMetadata() val mediaMetadata = getMediaMetadata()
if (mediaType == "book") { if (mediaType == "book") {
var chapters = getAudiobookChapters() val chapters = getAudiobookChapters()
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration()) val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else { } else {
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false) val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
} }
@@ -42,4 +42,15 @@ data class LocalMediaProgress(
isFinished = playbackSession.progress >= 0.99 isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null finishedAt = if (isFinished) lastUpdate else null
} }
@JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime
duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt
startedAt = serverMediaProgress.startedAt
}
} }
@@ -2,13 +2,11 @@ package com.audiobookshelf.app.data
import android.net.Uri import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.core.app.NotificationCompat
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData import com.audiobookshelf.app.player.MediaProgressSyncData
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.google.android.exoplayer2.C
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MediaMetadata
import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaInfo
@@ -57,16 +55,20 @@ class PlaybackSession(
@get:JsonIgnore @get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong() val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore @get:JsonIgnore
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
@get:JsonIgnore
val localLibraryItemId get() = localLibraryItem?.id ?: "" val localLibraryItemId get() = localLibraryItem?.id ?: ""
@get:JsonIgnore @get:JsonIgnore
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore @get:JsonIgnore
val progress get() = currentTime / getTotalDuration() val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@JsonIgnore @JsonIgnore
fun getCurrentTrackIndex():Int { fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) { for (i in 0..(audioTracks.size - 1)) {
var track = audioTracks[i] val track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) { if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
return i return i
} }
@@ -76,14 +78,14 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getCurrentTrackTimeMs():Long { fun getCurrentTrackTimeMs():Long {
var currentTrack = audioTracks[this.getCurrentTrackIndex()] val currentTrack = audioTracks[this.getCurrentTrackIndex()]
var time = currentTime - currentTrack.startOffset val time = currentTime - currentTrack.startOffset
return (time * 1000L).toLong() return (time * 1000L).toLong()
} }
@JsonIgnore @JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long { fun getTrackStartOffsetMs(index:Int):Long {
var currentTrack = audioTracks[index] val currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong() return (currentTrack.startOffset * 1000L).toLong()
} }
@@ -110,7 +112,7 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat { fun getMediaMetadataCompat(): MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder() val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
@@ -123,14 +125,14 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata { fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
var metadataBuilder = MediaMetadata.Builder() val metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle) .setTitle(displayTitle)
.setDisplayTitle(displayTitle) .setDisplayTitle(displayTitle)
.setArtist(displayAuthor) .setArtist(displayAuthor)
.setAlbumArtist(displayAuthor) .setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor) .setSubtitle(displayAuthor)
var contentUri = this.getContentUri(audioTrack) val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri) metadataBuilder.setMediaUri(contentUri)
return metadataBuilder.build() return metadataBuilder.build()
@@ -138,15 +140,15 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getMediaItems():List<MediaItem> { fun getMediaItems():List<MediaItem> {
var mediaItems:MutableList<MediaItem> = mutableListOf() val mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) { for (audioTrack in audioTracks) {
var mediaMetadata = this.getExoMediaMetadata(audioTrack) val mediaMetadata = this.getExoMediaMetadata(audioTrack)
var mediaUri = this.getContentUri(audioTrack) val mediaUri = this.getContentUri(audioTrack)
var mimeType = audioTrack.mimeType val mimeType = audioTrack.mimeType
var queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
var mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
mediaItems.add(mediaItem) mediaItems.add(mediaItem)
} }
return mediaItems return mediaItems
@@ -154,7 +156,7 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata { fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER) val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
coverPath?.let { coverPath?.let {
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"))) castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
@@ -169,11 +171,11 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem { fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
var castMetadata = getCastMediaMetadata(audioTrack) val castMetadata = getCastMediaMetadata(audioTrack)
var mediaUri = getContentUri(audioTrack) val mediaUri = getContentUri(audioTrack)
var mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply { val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
setContentUrl(mediaUri.toString()) setContentUrl(mediaUri.toString())
setContentType(audioTrack.mimeType) setContentType(audioTrack.mimeType)
setMetadata(castMetadata) setMetadata(castMetadata)
@@ -24,6 +24,6 @@ object DeviceManager {
} }
fun getBase64Id(id:String):String { fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.NO_WRAP) return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
} }
} }
@@ -19,6 +19,7 @@ class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
private fun getLocalLibraryItemId(mediaItemId:String):String { private fun getLocalLibraryItemId(mediaItemId:String):String {
return "local_" + DeviceManager.getBase64Id(mediaItemId) return "local_" + DeviceManager.getBase64Id(mediaItemId)
@@ -36,7 +37,7 @@ class FolderScanner(var ctx: Context) {
} }
} }
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl)) val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
if (df == null) { if (df == null) {
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl") Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
@@ -49,7 +50,7 @@ class FolderScanner(var ctx: Context) {
var mediaItemsUpToDate = 0 var mediaItemsUpToDate = 0
// Search for files in media item folder // Search for files in media item folder
var foldersFound = df.search(false, DocumentFileType.FOLDER) val foldersFound = df.search(false, DocumentFileType.FOLDER)
// Match folders found with local library items already saved in db // Match folders found with local library items already saved in db
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
@@ -57,7 +58,7 @@ class FolderScanner(var ctx: Context) {
// Remove existing items no longer there // Remove existing items no longer there
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli -> existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}") Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) } val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
if (fileFound == null) { if (fileFound == null) {
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}") Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
DeviceManager.dbManager.removeLocalLibraryItem(lli.id) DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
@@ -68,9 +69,9 @@ class FolderScanner(var ctx: Context) {
foldersFound.forEach { itemFolder -> foldersFound.forEach { itemFolder ->
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}") Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
var existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) } val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
var result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe) val result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++ if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++ else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
@@ -90,23 +91,23 @@ class FolderScanner(var ctx: Context) {
} }
fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult { fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
var itemFolderName = itemFolder.name ?: "" val itemFolderName = itemFolder.name ?: ""
var itemId = getLocalLibraryItemId(itemFolder.id) val itemId = getLocalLibraryItemId(itemFolder.id)
var existingLocalFiles = existingItem?.localFiles ?: mutableListOf() val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf() val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
var isNewOrUpdated = existingItem == null var isNewOrUpdated = existingItem == null
var audioTracks = mutableListOf<AudioTrack>() val audioTracks = mutableListOf<AudioTrack>()
var localFiles = mutableListOf<LocalFile>() val localFiles = mutableListOf<LocalFile>()
var index = 1 var index = 1
var startOffset = 0.0 var startOffset = 0.0
var coverContentUrl:String? = null var coverContentUrl:String? = null
var coverAbsolutePath:String? = null var coverAbsolutePath:String? = null
var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
var existingLocalFilesRemoved = existingLocalFiles.filter { elf -> val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
} }
if (existingLocalFilesRemoved.isNotEmpty()) { if (existingLocalFilesRemoved.isNotEmpty()) {
@@ -115,14 +116,14 @@ class FolderScanner(var ctx: Context) {
} }
filesInFolder.forEach { file -> filesInFolder.forEach { file ->
var mimeType = file.mimeType ?: "" val mimeType = file.mimeType ?: ""
var filename = file.name ?: "" val filename = file.name ?: ""
var isAudio = mimeType.startsWith("audio") val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
var localFileId = DeviceManager.getBase64Id(file.id) val localFileId = DeviceManager.getBase64Id(file.id)
var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length()) val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
localFiles.add(localFile) localFiles.add(localFile)
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
@@ -130,7 +131,7 @@ class FolderScanner(var ctx: Context) {
if (isAudio) { if (isAudio) {
var audioTrackToAdd:AudioTrack? = null var audioTrackToAdd:AudioTrack? = null
var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId } val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
if (existingAudioTrack != null) { // Update existing audio track if (existingAudioTrack != null) { // Update existing audio track
if (existingAudioTrack.index != index) { if (existingAudioTrack.index != index) {
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index") Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
@@ -148,7 +149,7 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}") Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
// TODO: Make asynchronous // TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
if (existingAudioTrack != null) { if (existingAudioTrack != null) {
// Update audio probe data on existing audio track // Update audio probe data on existing audio track
@@ -170,7 +171,7 @@ class FolderScanner(var ctx: Context) {
index++ index++
audioTracks.add(audioTrackToAdd) audioTracks.add(audioTrackToAdd)
} else { } else {
var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
if (existingLocalFile == null) { if (existingLocalFile == null) {
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}") Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
@@ -207,8 +208,8 @@ class FolderScanner(var ctx: Context) {
return ItemScanResult.UPDATED return ItemScanResult.UPDATED
} else if (audioTracks.isNotEmpty()) { } else if (audioTracks.isNotEmpty()) {
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath) val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
var localLibraryItem = localMediaItem.getLocalLibraryItem() val localLibraryItem = localMediaItem.getLocalLibraryItem()
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return ItemScanResult.ADDED return ItemScanResult.ADDED
} else { } else {
@@ -217,9 +218,9 @@ class FolderScanner(var ctx: Context) {
} }
// Scan item after download and create local library item // Scan item after download and create local library item
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? { fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):DownloadItemScanResult? {
var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
var foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf() val foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = "" var itemFolderId = ""
var itemFolderUrl = "" var itemFolderUrl = ""
@@ -238,20 +239,22 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "scanDownloadItem failed to find media folder") Log.d(tag, "scanDownloadItem failed to find media folder")
return null return null
} }
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl)) val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) { if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}") Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return null return null
} }
var localLibraryItemId = getLocalLibraryItemId(itemFolderId) val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId") Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder // Search for files in media item folder
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) // m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem? = null var localLibraryItem:LocalLibraryItem? = null
if (downloadItem.mediaType == "book") { if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId) localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
@@ -264,10 +267,10 @@ class FolderScanner(var ctx: Context) {
} }
} }
var audioTracks:MutableList<AudioTrack> = mutableListOf() val audioTracks:MutableList<AudioTrack> = mutableListOf()
filesFound.forEach { docFile -> filesFound.forEach { docFile ->
var itemPart = downloadItem.downloadItemParts.find { itemPart -> val itemPart = downloadItem.downloadItemParts.find { itemPart ->
itemPart.filename == docFile.name itemPart.filename == docFile.name
} }
if (itemPart == null) { if (itemPart == null) {
@@ -275,31 +278,32 @@ class FolderScanner(var ctx: Context) {
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}") Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
} }
} else if (itemPart.audioTrack != null) { // Is audio track } else if (itemPart.audioTrack != null) { // Is audio track
var audioTrackFromServer = itemPart.audioTrack val audioTrackFromServer = itemPart.audioTrack
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}") Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}")
var localFileId = DeviceManager.getBase64Id(docFile.id) val localFileId = DeviceManager.getBase64Id(docFile.id)
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile) localLibraryItem.localFiles.add(localFile)
// TODO: Make asynchronous // TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track // Create new audio track
var track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1) val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
audioTracks.add(track) audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}") Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
// Add podcast episodes to library // Add podcast episodes to library
itemPart.episode?.let { podcastEpisode -> itemPart.episode?.let { podcastEpisode ->
var podcast = localLibraryItem.media as Podcast val podcast = localLibraryItem.media as Podcast
podcast.addEpisode(track, podcastEpisode) val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}") Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
} }
} else { // Cover image } else { // Cover image
var localFileId = DeviceManager.getBase64Id(docFile.id) val localFileId = DeviceManager.getBase64Id(docFile.id)
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl localLibraryItem.coverContentUrl = localFile.contentUrl
@@ -330,13 +334,40 @@ class FolderScanner(var ctx: Context) {
localLibraryItem.media.setAudioTracks(audioTracks) localLibraryItem.media.setAudioTracks(audioTracks)
} }
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = false,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId)
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return localLibraryItem return downloadItemScanResult
} }
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? { fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl)) val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
if (df == null) { if (df == null) {
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}") Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
@@ -347,7 +378,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false var wasUpdated = false
// Search for files in media item folder // Search for files in media item folder
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}") Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach { filesFound.forEach {
@@ -358,10 +389,10 @@ class FolderScanner(var ctx: Context) {
} }
} }
var existingAudioTracks = localLibraryItem.media.getAudioTracks() val existingAudioTracks = localLibraryItem.media.getAudioTracks()
// Remove any files no longer found in library item folder // Remove any files no longer found in library item folder
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id } val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
existingLocalFileIds.forEach { localFileId -> existingLocalFileIds.forEach { localFileId ->
Log.d(tag, "Checking local file id is there $localFileId") Log.d(tag, "Checking local file id is there $localFileId")
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) { if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
@@ -377,12 +408,12 @@ class FolderScanner(var ctx: Context) {
} }
filesFound.forEach { docFile -> filesFound.forEach { docFile ->
var localFileId = DeviceManager.getBase64Id(docFile.id) val localFileId = DeviceManager.getBase64Id(docFile.id)
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId } val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) { if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
if (existingLocalFile == null) { if (existingLocalFile == null) {
localLibraryItem.localFiles.add(localFile) localLibraryItem.localFiles.add(localFile)
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}") Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
@@ -390,22 +421,26 @@ class FolderScanner(var ctx: Context) {
if (localFile.isAudioFile()) { if (localFile.isAudioFile()) {
// TODO: Make asynchronous // TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
var existingTrack = existingAudioTracks.find { audioTrack -> val existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id audioTrack.localFileId == localFile.id
} }
if (existingTrack == null) { if (existingTrack == null) {
// Create new audio track // Create new audio track
var lastTrack = existingAudioTracks.lastOrNull() val lastTrack = existingAudioTracks.lastOrNull()
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0) val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null) val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track) localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true wasUpdated = true
} else { } else {
existingTrack.audioProbeResult = audioProbeResult existingTrack.audioProbeResult = audioProbeResult
// TODO: Update data found from probe // TODO: Update data found from probe
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
wasUpdated = true wasUpdated = true
} }
} else { // Check if cover is empty } else { // Check if cover is empty
@@ -1,27 +1,55 @@
package com.audiobookshelf.app.media package com.audiobookshelf.app.media
import android.bluetooth.BluetoothClass
import android.content.Context import android.content.Context
import android.support.v4.media.MediaBrowserCompat
import android.util.Log import android.util.Log
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.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import java.util.* import java.util.*
import io.paperdb.Paper import io.paperdb.Paper
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager" val tag = "MediaManager"
var serverLibraryItems = listOf<LibraryItem>() var serverLibraryItems = listOf<LibraryItem>()
var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null
var serverPodcastEpisodes = listOf<PodcastEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>() var serverLibraryCategories = listOf<LibraryCategory>()
var serverLibraries = listOf<Library>() var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null
fun initializeAndroidAuto() { fun initializeAndroidAuto() {
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper") Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
Paper.init(ctx) Paper.init(ctx)
} }
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
}
fun checkResetServerItems() {
// When opening android auto need to check if still connected to server
// and reset any server data already set
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
serverPodcastEpisodes = listOf()
serverLibraryCategories = listOf()
serverLibraries = listOf()
serverLibraryItems = listOf()
selectedLibraryId = ""
}
}
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) { if (serverLibraryCategories.isNotEmpty()) {
cb(serverLibraryCategories) cb(serverLibraryCategories)
@@ -33,17 +61,75 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) { fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty()) { if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(serverLibraryItems) cb(serverLibraryItems)
} else { } else {
apiHandler.getLibraryItems(libraryId) { libraryItems -> apiHandler.getLibraryItems(libraryId) { libraryItems ->
serverLibraryItems = libraryItems val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
cb(libraryItems) if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId
serverLibraryItems = libraryItemsWithAudio
cb(libraryItemsWithAudio)
} }
} }
} }
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
if (libraryItemId.startsWith("local")) {
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
} else {
Log.d(tag, "loadLibraryItem: $libraryItemId")
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
Log.d(tag, "loadLibraryItem: Got library item $libraryItem")
cb(libraryItem)
}
}
}
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
selectedLibraryItemWrapper = libraryItemWrapper
libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
}
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
}
}
}
}
}
fun loadLibraries(cb: (List<Library>) -> Unit) { fun loadLibraries(cb: (List<Library>) -> Unit) {
if (serverLibraries.isNotEmpty()) { if (serverLibraries.isNotEmpty()) {
cb(serverLibraries) cb(serverLibraries)
@@ -55,11 +141,53 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
var successfulPing = false
suspendCoroutine<Boolean> { cont ->
apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it
cont.resume(it)
}
}
return successfulPing
}
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
if (!apiHandler.isOnline()) cb(false)
else {
coroutineScope {
var hasValidConn = false
// First check if the current selected config is pingable
DeviceManager.serverConnectionConfig?.let {
hasValidConn = checkServerConnection(it)
Log.d(tag, "checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn")
}
if (!hasValidConn) {
// Loop through available configs and check if can connect
for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) {
val result = checkServerConnection(config)
if (result) {
hasValidConn = true
DeviceManager.serverConnectionConfig = config
break
}
}
}
cb(hasValidConn)
}
}
}
// TODO: Load currently listening category for local items // TODO: Load currently listening category for local items
fun loadLocalCategory():List<LibraryCategory> { fun loadLocalCategory():List<LibraryCategory> {
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
var localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
var cats = mutableListOf<LibraryCategory>() val cats = mutableListOf<LibraryCategory>()
if (localBooks.isNotEmpty()) { if (localBooks.isNotEmpty()) {
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true)) cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
} }
@@ -69,45 +197,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return cats return cats
} }
fun loadAndroidAutoItems(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
Log.d(tag, "Load android auto items for library id $libraryId") Log.d(tag, "Load android auto items")
var cats = mutableListOf<LibraryCategory>() val cats = mutableListOf<LibraryCategory>()
var localCategories = loadLocalCategory() val localCategories = loadLocalCategory()
cats.addAll(localCategories) cats.addAll(localCategories)
// Connected to server and has internet - load other cats // Check if any valid server connection if not use locally downloaded books
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) { checkSetValidServerConnectionConfig { isConnected ->
if (!DeviceManager.isConnectedToServer) { if (isConnected) {
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig() serverConfigIdUsed = DeviceManager.serverConnectionConfigId
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
}
loadLibraries { libraries -> loadLibraries { libraries ->
var library = libraries.find { it.id == libraryId } ?: 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(libraryId) { libraryCategories -> loadLibraryCategories(library.id) { libraryCategories ->
// Only using book or podcast library categories for now // Only using book or podcast library categories for now
libraryCategories.forEach { libraryCategories.forEach {
Log.d(tag, "Found library category ${it.label} with type ${it.type}") // Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) { if (it.type == library.mediaType) {
Log.d(tag, "Using library category ${it.id}") // Log.d(tag, "Using library category ${it.id}")
cats.add(it) cats.add(it)
}
} }
}
loadLibraryItems(libraryId) { libraryItems ->
var mainCat = LibraryCategory("library", "Library", library.mediaType, libraryItems, false)
cats.add(mainCat)
cb(cats) cb(cats)
} }
} }
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
} }
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
} }
} }
@@ -115,11 +237,24 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
if (serverLibraryItems.isNotEmpty()) { if (serverLibraryItems.isNotEmpty()) {
return serverLibraryItems[0] return serverLibraryItems[0]
} else { } else {
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
return if (localBooks.isNotEmpty()) return localBooks[0] else null return if (localBooks.isNotEmpty()) return localBooks[0] else null
} }
} }
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else {
val podcastEpisode = serverPodcastEpisodes.find { it.id == id }
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
} else {
null
}
}
}
fun getById(id:String) : LibraryItemWrapper? { fun getById(id:String) : LibraryItemWrapper? {
if (id.startsWith("local")) { if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItem(id) return DeviceManager.dbManager.getLocalLibraryItem(id)
@@ -135,13 +270,13 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun play(libraryItemWrapper:LibraryItemWrapper, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) { if (libraryItemWrapper is LocalLibraryItem) {
var localLibraryItem = libraryItemWrapper as LocalLibraryItem val localLibraryItem = libraryItemWrapper as LocalLibraryItem
cb(localLibraryItem.getPlaybackSession(null)) cb(localLibraryItem.getPlaybackSession(episode))
} else { } else {
var libraryItem = libraryItemWrapper as LibraryItem val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,"",false, mediaPlayer) { apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "",playItemRequestPayload) {
cb(it) cb(it)
} }
} }
@@ -59,9 +59,8 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? { private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
// Block on downloading artwork.
try { try {
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions) Glide.with(playerNotificationService)
.asBitmap() .asBitmap()
.load(uri) .load(uri)
.placeholder(R.drawable.icon) .placeholder(R.drawable.icon)
@@ -71,7 +70,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions) Glide.with(playerNotificationService)
.asBitmap() .asBitmap()
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)) .load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE) .submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
@@ -7,14 +7,15 @@ import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.Library
import com.audiobookshelf.app.data.LibraryCategory import com.audiobookshelf.app.data.LibraryCategory
import com.audiobookshelf.app.data.LibraryItem import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalLibraryItem import com.audiobookshelf.app.data.LocalLibraryItem
class BrowseTree( class BrowseTree(
val context: Context, val context: Context,
libraryCategories: List<LibraryCategory> libraryCategories: List<LibraryCategory>,
libraries: List<Library>
) { ) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>() private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@@ -41,22 +42,22 @@ class BrowseTree(
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
}.build() }.build()
val allMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_books).toString())
}.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(context, R.drawable.exo_icon_downloaddone).toString())
}.build() }.build()
val librariesMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
}.build()
// Server continue Listening cat // Server continue Listening cat
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory -> libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
var continueListeningMediaMetadata = continueListeningCategory.entities.map { liw -> val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
var libraryItem = liw as LibraryItem val libraryItem = liw as LibraryItem
libraryItem.getMediaMetadata() libraryItem.getMediaMetadata()
} }
if (continueListeningMediaMetadata.isNotEmpty()) { if (continueListeningMediaMetadata.isNotEmpty()) {
@@ -69,30 +70,32 @@ class BrowseTree(
} }
} }
rootList += allMetadata if (libraries.isNotEmpty()) {
rootList += downloadsMetadata rootList += librariesMetadata
// Server library cat libraries.forEach { library ->
libraryCategories.find { it.id == "library" }?.let { libraryCategory -> val libraryMediaMetadata = library.getMediaMetadata()
var libraryMediaMetadata = libraryCategory.entities.map { libc -> val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
var libraryItem = libc as LibraryItem children += libraryMediaMetadata
libraryItem.getMediaMetadata() mediaIdToChildren[LIBRARIES_ROOT] = children
}
libraryMediaMetadata.forEach {
val children = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[ALL_ROOT] = children
} }
} }
rootList += downloadsMetadata
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat -> libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
var localMediaMetadata = localBooksCat.entities.map { libc -> localBooksCat.entities.forEach { libc ->
var libraryItem = libc as LocalLibraryItem val libraryItem = libc as LocalLibraryItem
libraryItem.getMediaMetadata() val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context)
mediaIdToChildren[DOWNLOADS_ROOT] = children
} }
localMediaMetadata.forEach { }
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
localPodcastsCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf() val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += it children += libraryItem.getMediaMetadata(context)
mediaIdToChildren[DOWNLOADS_ROOT] = children mediaIdToChildren[DOWNLOADS_ROOT] = children
} }
} }
@@ -104,6 +107,6 @@ class BrowseTree(
} }
const val AUTO_BROWSE_ROOT = "/" const val AUTO_BROWSE_ROOT = "/"
const val ALL_ROOT = "__ALL__"
const val CONTINUE_ROOT = "__CONTINUE__" const val CONTINUE_ROOT = "__CONTINUE__"
const val DOWNLOADS_ROOT = "__DOWNLOADS__" const val DOWNLOADS_ROOT = "__DOWNLOADS__"
const val LIBRARIES_ROOT = "__LIBRARIES__"
@@ -4,6 +4,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.LocalMediaProgress import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
@@ -17,15 +18,14 @@ data class MediaProgressSyncData(
var currentTime:Double // seconds var currentTime:Double // seconds
) )
class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) { class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
private val tag = "MediaProgressSync" private val tag = "MediaProgressSync"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private val apiHandler = apiHandler
private var listeningTimerTask: TimerTask? = null private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false var listeningTimerRunning:Boolean = false
private var lastSyncTime:Long = 0 private var lastSyncTime:Long = 0
private var failedSyncs:Int = 0
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null var currentLocalMediaProgress: LocalMediaProgress? = null
@@ -43,6 +43,7 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
currentLocalMediaProgress = null currentLocalMediaProgress = null
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
lastSyncTime = 0L lastSyncTime = 0L
failedSyncs = 0
} else { } else {
return return
} }
@@ -54,7 +55,7 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) { if (playerNotificationService.currentPlayer.isPlaying) {
var currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime) sync(currentTime)
} }
} }
@@ -65,20 +66,30 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
if (!listeningTimerRunning) return if (!listeningTimerRunning) return
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
var currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime) sync(currentTime)
reset() reset()
} }
fun syncFromServerProgress(mediaProgress: MediaProgress) {
currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
}
}
fun sync(currentTime:Double) { fun sync(currentTime:Double) {
var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) { if (diffSinceLastSync < 1000L) {
return return
} }
var listeningTimeToAdd = diffSinceLastSync / 1000L val listeningTimeToAdd = diffSinceLastSync / 1000L
lastSyncTime = System.currentTimeMillis() lastSyncTime = System.currentTimeMillis()
var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime) val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData) currentPlaybackSession?.syncData(syncData)
if (currentIsLocal) { if (currentIsLocal) {
@@ -87,23 +98,39 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
DeviceManager.dbManager.saveLocalPlaybackSession(it) DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it) saveLocalProgress(it)
// Send sync to server also if connected to this server and local item belongs to this server // Local library item is linked to a server library item
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { if (!it.libraryItemId.isNullOrEmpty()) {
apiHandler.sendLocalProgressSync(it) { // Send sync to server also if connected to this server and local item belongs to this server
Log.d(tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime") if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) {
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
}
} }
} }
} }
} else { } else {
apiHandler.sendProgressSync(currentSessionId, syncData) { apiHandler.sendProgressSync(currentSessionId, syncData) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime") if (it) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.d(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
}
} }
} }
} }
private fun saveLocalProgress(playbackSession:PlaybackSession) { private fun saveLocalProgress(playbackSession:PlaybackSession) {
if (currentLocalMediaProgress == null) { if (currentLocalMediaProgress == null) {
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId) val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
if (mediaProgress == null) { if (mediaProgress == null) {
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress() currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else { } else {
@@ -126,5 +153,6 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
currentPlaybackSession = null currentPlaybackSession = null
currentLocalMediaProgress = null currentLocalMediaProgress = null
lastSyncTime = 0L lastSyncTime = 0L
failedSyncs = 0
} }
} }
@@ -9,11 +9,8 @@ import android.os.Message
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import kotlinx.coroutines.Dispatchers import com.audiobookshelf.app.data.PodcastEpisode
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
@@ -27,10 +24,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPrepare() { override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true) playerNotificationService.preparePlayer(it,true,null)
} }
} }
} }
@@ -49,10 +46,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromSearch(query: String?, extras: Bundle?) { override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM SEARCH $query") Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true) playerNotificationService.preparePlayer(it,true,null)
} }
} }
} }
@@ -90,17 +87,27 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var libraryItemWrapper: LibraryItemWrapper? = null var libraryItemWrapper: LibraryItemWrapper? = null
var podcastEpisode: PodcastEpisode? = null
if (mediaId.isNullOrEmpty()) { if (mediaId.isNullOrEmpty()) {
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem() libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else { } else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId) libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
}
} }
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true) playerNotificationService.preparePlayer(it,true,null)
} }
} }
} }
@@ -112,10 +119,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
fun handleCallMediaButton(intent: Intent): Boolean { fun handleCallMediaButton(intent: Intent): Boolean {
if(Intent.ACTION_MEDIA_BUTTON == intent.action) { if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
when (keyEvent?.getKeyCode()) { if (keyEvent?.action == KeyEvent.ACTION_UP) {
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_HEADSETHOOK -> { KeyEvent.KEYCODE_HEADSETHOOK -> {
Log.d(tag, "handleCallMediaButton: Headset Hook")
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
if (playerNotificationService.mPlayer.isPlaying) if (playerNotificationService.mPlayer.isPlaying)
playerNotificationService.pause() playerNotificationService.pause()
@@ -125,6 +135,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PLAY -> { KeyEvent.KEYCODE_MEDIA_PLAY -> {
Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
playerNotificationService.play() playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer() playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
@@ -132,6 +143,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Pause")
if (0 == mediaButtonClickCount) playerNotificationService.pause() if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
@@ -145,6 +157,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
playerNotificationService.closePlayback() playerNotificationService.closePlayback()
} }
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
if (playerNotificationService.mPlayer.isPlaying) { if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause() if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount() handleMediaButtonClickCount()
@@ -157,7 +170,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
} }
else -> { else -> {
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}") Log.d(tag, "KeyCode:${keyEvent.keyCode}")
return false return false
} }
} }
@@ -166,7 +179,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return true return true
} }
fun handleMediaButtonClickCount() { private fun handleMediaButtonClickCount() {
mediaButtonClickCount++ mediaButtonClickCount++
if (1 == mediaButtonClickCount) { if (1 == mediaButtonClickCount) {
Timer().schedule(mediaButtonClickTimeout) { Timer().schedule(mediaButtonClickTimeout) {
@@ -7,8 +7,8 @@ import android.os.Looper
import android.os.ResultReceiver import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
@@ -30,9 +30,9 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepare(playWhenReady: Boolean) { override fun onPrepare(playWhenReady: Boolean) {
Log.d(tag, "ON PREPARE $playWhenReady") Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady) playerNotificationService.preparePlayer(it,playWhenReady,null)
} }
} }
} }
@@ -41,12 +41,22 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
var libraryItemWrapper: LibraryItemWrapper? = playerNotificationService.mediaManager.getById(mediaId) var libraryItemWrapper: LibraryItemWrapper? = null
var podcastEpisode: PodcastEpisode? = null
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
}
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady) playerNotificationService.preparePlayer(it,playWhenReady,null)
} }
} }
} }
@@ -55,10 +65,10 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query") Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady) playerNotificationService.preparePlayer(it,playWhenReady,null)
} }
} }
} }
@@ -5,6 +5,8 @@ import com.audiobookshelf.app.data.PlayerState
import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 60000 // 1 minute
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener" var tag = "PlayerListener"
@@ -15,7 +17,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
private var onSeekBack: Boolean = false private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
var errorMessage = error.message ?: "Unknown Error" val errorMessage = error.message ?: "Unknown Error"
Log.e(tag, "onPlayerError $errorMessage") Log.e(tag, "onPlayerError $errorMessage")
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
} }
@@ -66,23 +68,37 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}") Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}")
if (player.isPlaying) { if (player.isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0) { if (lastPauseTime > 0) {
if (onSeekBack) onSeekBack = false if (onSeekBack) onSeekBack = false
else { else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var backTime = calcPauseSeekBackTime() var backTime = calcPauseSeekBackTime()
if (backTime > 0) { if (backTime > 0) {
if (backTime >= playerNotificationService.mPlayer.currentPosition) backTime = playerNotificationService.mPlayer.currentPosition - 500 if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500
Log.d(tag, "SeekBackTime $backTime") Log.d(tag, "SeekBackTime $backTime")
onSeekBack = true onSeekBack = true
playerNotificationService.seekBackward(backTime) playerNotificationService.seekBackward(backTime)
} else {
Log.d(tag, "SeekBackTime: back time is 0")
} }
} }
// Check if playback session still exists or sync media progress if updated
if (lastPauseTime > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress()
if (!shouldCarryOn) return
}
} }
} else lastPauseTime = System.currentTimeMillis() } else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval // Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}") Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
if (player.isPlaying) { if (player.isPlaying) {
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.start() playerNotificationService.mediaProgressSyncer.start()
} else { } else {
playerNotificationService.mediaProgressSyncer.stop() playerNotificationService.mediaProgressSyncer.stop()
@@ -94,14 +110,12 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
private fun calcPauseSeekBackTime() : Long { private fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0 if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime val time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long val seekback: Long
if (time < 60000) seekback = 0 if (time < 3000) seekback = 0
else if (time < 120000) seekback = 10000 else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s
else if (time < 300000) seekback = 15000 else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
else if (time < 1800000) seekback = 20000 else seekback = 29500 // 30m and up = jump back 30s
else if (time < 3600000) seekback = 25000
else seekback = 29500
return seekback return seekback
} }
} }
@@ -9,7 +9,6 @@ import android.hardware.SensorManager
import android.os.* import android.os.*
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
@@ -18,7 +17,9 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
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.data.* import com.audiobookshelf.app.data.*
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
@@ -31,7 +32,6 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.* import com.google.android.exoplayer2.upstream.*
import io.paperdb.Paper
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
@@ -48,12 +48,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun onPlaybackClosed() fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean) fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: PlaybackMetadata) fun onMetadata(metadata: PlaybackMetadata)
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition: Long) fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int) fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
fun onPlaybackFailed(errorMessage:String) fun onPlaybackFailed(errorMessage:String)
fun onMediaPlayerChanged(mediaPlayer:String) fun onMediaPlayerChanged(mediaPlayer:String)
fun onProgressSyncFailing()
} }
private val tag = "PlayerService" private val tag = "PlayerService"
@@ -77,13 +77,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
lateinit var sleepTimerManager:SleepTimerManager lateinit var sleepTimerManager:SleepTimerManager
lateinit var mediaProgressSyncer:MediaProgressSyncer lateinit var mediaProgressSyncer:MediaProgressSyncer
private var notificationId = 10; private var notificationId = 10
private var channelId = "audiobookshelf_channel" private var channelId = "audiobookshelf_channel"
private var channelName = "Audiobookshelf Channel" private var channelName = "Audiobookshelf Channel"
private var currentPlaybackSession:PlaybackSession? = null private var currentPlaybackSession:PlaybackSession? = null
private var initialPlaybackRate:Float? = null
var isAndroidAuto = false private var isAndroidAuto = false
// The following are used for the shake detection // The following are used for the shake detection
private var isShakeSensorRegistered:Boolean = false private var isShakeSensorRegistered:Boolean = false
@@ -101,7 +102,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Android Auto Media Browser Service // Android Auto Media Browser Service
if (SERVICE_INTERFACE == intent.action) { if (SERVICE_INTERFACE == intent.action) {
Log.d(tag, "Is Media Browser Service") Log.d(tag, "Is Media Browser Service")
return super.onBind(intent); return super.onBind(intent)
} }
return binder return binder
} }
@@ -164,7 +165,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
ctx = this ctx = this
// Initialize player // Initialize player
var customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs( val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer 1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer 1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start 1000 * 5, // 5s playback start
@@ -178,7 +179,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.build() .build()
mPlayer.setHandleAudioBecomingNoisy(true) mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this)) mPlayer.addListener(PlayerListener(this))
var audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build() val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true) mPlayer.setAudioAttributes(audioAttributes, true)
currentPlayer = mPlayer currentPlayer = mPlayer
@@ -205,7 +206,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val sessionActivityPendingIntent = val sessionActivityPendingIntent =
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, 0) PendingIntent.getActivity(this, 0, sessionIntent, PendingIntent.FLAG_IMMUTABLE)
} }
mediaSession = MediaSessionCompat(this, tag) mediaSession = MediaSessionCompat(this, tag)
@@ -246,11 +247,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
val coverUri = currentPlaybackSession!!.getCoverUri()
// Fix for local images crashing on Android 11 for specific devices
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
ctx.grantUriPermission(
"com.android.systemui",
coverUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
return MediaDescriptionCompat.Builder() return MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id) .setMediaId(currentPlaybackSession!!.id)
.setTitle(currentPlaybackSession!!.displayTitle) .setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor) .setSubtitle(currentPlaybackSession!!.displayAuthor)
.setIconUri(currentPlaybackSession!!.getCoverUri()).build() .setIconUri(coverUri).build()
} }
} }
@@ -275,7 +286,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
/* /*
User callable methods User callable methods
*/ */
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean) { fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) { if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) {
@@ -296,9 +310,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onPlaybackSession(playbackSession) clientEventEmitter?.onPlaybackSession(playbackSession)
var metadata = playbackSession.getMediaMetadataCompat() val metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
var mediaItems = playbackSession.getMediaItems() 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")
@@ -307,18 +321,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
if (mPlayer == currentPlayer) { if (mPlayer == currentPlayer) {
var mediaSource:MediaSource val mediaSource:MediaSource
var dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
if (playbackSession.isLocal) { if (playbackSession.isLocal) {
Log.d(tag, "Playing Local Item") Log.d(tag, "Playing Local Item")
val dataSourceFactory = DefaultDataSource.Factory(ctx)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else if (!playbackSession.isHLS) { } else if (!playbackSession.isHLS) {
Log.d(tag, "Direct Playing Item") Log.d(tag, "Direct Playing Item")
val dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else { } else {
Log.d(tag, "Playing HLS Item") Log.d(tag, "Playing HLS Item")
val dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")) dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${DeviceManager.token}"))
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} }
@@ -330,8 +347,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size)) currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size))
Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}") Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}")
var currentTrackIndex = playbackSession.getCurrentTrackIndex() val currentTrackIndex = playbackSession.getCurrentTrackIndex()
var currentTrackTime = playbackSession.getCurrentTrackTimeMs() val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime") Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime")
currentPlayer.seekTo(currentTrackIndex, currentTrackTime) currentPlayer.seekTo(currentTrackIndex, currentTrackTime)
} else { } else {
@@ -340,16 +357,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}") Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
currentPlayer.playWhenReady = playWhenReady currentPlayer.playWhenReady = playWhenReady
currentPlayer.setPlaybackSpeed(1f) // TODO: Playback speed should come from settings currentPlayer.setPlaybackSpeed(playbackRateToUse)
currentPlayer.prepare() currentPlayer.prepare()
} else if (castPlayer != null) { } else if (castPlayer != null) {
var currentTrackIndex = playbackSession.getCurrentTrackIndex() val currentTrackIndex = playbackSession.getCurrentTrackIndex()
var currentTrackTime = playbackSession.getCurrentTrackTimeMs() val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
var mediaType = playbackSession.mediaType val mediaType = playbackSession.mediaType
Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType") Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType")
castPlayer?.load(mediaItems, currentTrackIndex, currentTrackTime, playWhenReady, 1f, mediaType) castPlayer?.load(mediaItems, currentTrackIndex, currentTrackTime, playWhenReady, playbackRateToUse, mediaType)
} }
} }
@@ -357,14 +375,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// On error and was attempting to direct play - fallback to transcode // On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession -> currentPlaybackSession?.let { playbackSession ->
if (playbackSession.isDirectPlay) { if (playbackSession.isDirectPlay) {
var mediaPlayer = getMediaPlayer() val playItemRequestPayload = getPlayItemRequestPayload(true)
Log.d(tag, "Fallback to transcode $mediaPlayer") Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer")
var libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
var episodeId = playbackSession.episodeId val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
preparePlayer(it, true) preparePlayer(it, true, null)
} }
} }
} else { } else {
@@ -374,8 +392,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
fun startNewPlaybackSession() {
currentPlaybackSession?.let { playbackSession ->
val forceTranscode = playbackSession.isHLS // If already HLS then force
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
}
fun switchToPlayer(useCastPlayer: Boolean) { fun switchToPlayer(useCastPlayer: Boolean) {
var wasPlaying = currentPlayer.isPlaying val wasPlaying = currentPlayer.isPlaying
if (useCastPlayer) { if (useCastPlayer) {
if (currentPlayer == castPlayer) { if (currentPlayer == castPlayer) {
Log.d(tag, "switchToPlayer: Already using Cast Player " + castPlayer?.deviceInfo) Log.d(tag, "switchToPlayer: Already using Cast Player " + castPlayer?.deviceInfo)
@@ -417,7 +450,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (wasPlaying) { // media is paused when switching players if (wasPlaying) { // media is paused when switching players
clientEventEmitter?.onPlayingUpdate(false) clientEventEmitter?.onPlayingUpdate(false)
} }
preparePlayer(it, false) preparePlayer(it, false, null)
} }
} }
@@ -450,7 +483,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun getDuration() : Long { fun getDuration() : Long {
return currentPlayer.duration return currentPlaybackSession?.totalDurationMs ?: 0L
} }
fun getCurrentBookTitle() : String? { fun getCurrentBookTitle() : String? {
@@ -465,6 +498,67 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.id return currentPlaybackSession?.id
} }
// Called from PlayerListener play event
// check with server if progress has updated since last play and sync progress update
fun checkCurrentSessionProgress():Boolean {
if (currentPlaybackSession == null) return true
currentPlaybackSession?.let { playbackSession ->
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
return true // carry on
}
if (playbackSession.isLocal) {
// Local playback session check if server has updated media progress
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId) { mediaProgress ->
if (mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
Handler(Looper.getMainLooper()).post {
seekPlayer(playbackSession.currentTimeMs)
}
}
Handler(Looper.getMainLooper()).post {
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
} else {
// Streaming from server so check if playback session still exists on server
Log.d(
tag,
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
)
apiHandler.getPlaybackSession(playbackSession.id) {
if (it == null) {
Log.d(
tag,
"checkCurrentSessionProgress: Playback session does not exist on server - start new playback session"
)
Handler(Looper.getMainLooper()).post {
currentPlayer.pause()
startNewPlaybackSession()
}
} else {
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
Handler(Looper.getMainLooper()).post {
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
}
}
}
return false
}
fun play() { fun play() {
if (currentPlayer.isPlaying) { if (currentPlayer.isPlaying) {
Log.d(tag, "Already playing") Log.d(tag, "Already playing")
@@ -496,8 +590,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time") Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time")
if (currentPlayer.mediaItemCount > 1) { if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0 currentPlaybackSession?.currentTime = time / 1000.0
var newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
var newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset) currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else { } else {
currentPlayer.seekTo(time) currentPlayer.seekTo(time)
@@ -505,11 +599,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun seekForward(amount: Long) { fun seekForward(amount: Long) {
currentPlayer.seekTo(currentPlayer.currentPosition + amount) seekPlayer(getCurrentTime() + amount)
} }
fun seekBackward(amount: Long) { fun seekBackward(amount: Long) {
currentPlayer.seekTo(currentPlayer.currentPosition - amount) seekPlayer(getCurrentTime() - amount)
} }
fun setPlaybackSpeed(speed: Float) { fun setPlaybackSpeed(speed: Float) {
@@ -525,7 +619,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun sendClientMetadata(playerState: PlayerState) { fun sendClientMetadata(playerState: PlayerState) {
var duration = currentPlaybackSession?.getTotalDuration() ?: 0.0 val duration = currentPlaybackSession?.getTotalDuration() ?: 0.0
clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState)) clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState))
} }
@@ -533,6 +627,29 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player" return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
} }
fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE
manufacturer: Google
model: Pixel 6
brand: google
sdkVersion: 32
appVersion: 0.9.46-beta
*/
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
}
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
}
fun getContext():Context {
return ctx
}
fun alertSyncFailing() {
clientEventEmitter?.onProgressSyncFailing()
}
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // MEDIA BROWSER STUFF (ANDROID AUTO)
// //
@@ -545,6 +662,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val AUTO_MEDIA_ROOT = "/" private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__" private val ALL_ROOT = "__ALL__"
private val LIBRARIES_ROOT = "__LIBRARIES__"
private lateinit var browseTree:BrowseTree private lateinit var browseTree:BrowseTree
@@ -570,6 +688,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.initializeAndroidAuto() mediaManager.initializeAndroidAuto()
isStarted = true isStarted = true
} }
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
isAndroidAuto = true isAndroidAuto = true
@@ -590,32 +709,66 @@ 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")
var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE var flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach() result.detach()
mediaManager.loadAndroidAutoItems("main") { libraryCategories -> if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
browseTree = BrowseTree(this, libraryCategories) Log.d(tag, "Loading podcast episodes")
val children = browseTree[parentMediaId]?.map { item -> mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
MediaBrowserCompat.MediaItem(item.description, flag) result.sendResult(it)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) } else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
}
// TODO: For using sub menus. Check if this is the root menu: mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
// if (AUTO_MEDIA_ROOT == parentMediaId) { val children = libraryItems.map { libraryItem ->
// build the MediaItem objects for the top level, val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
// and put them in the mediaItems list
// } else { if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
// examine the passed parentMediaId to see which submenu we're at, flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
// and put the children of that menu in the mediaItems list }
// }
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, flag)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
localPodcasts.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(localBrowseItems)
} else { // Load categories
mediaManager.loadAndroidAutoItems() { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag")
MediaBrowserCompat.MediaItem(item.description, flag)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
} }
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("main") { libraryCategories -> mediaManager.loadAndroidAutoItems() { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories) browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
val children = browseTree[ALL_ROOT]?.map { item -> val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
@@ -649,7 +802,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
shakeSensorUnregisterTask?.cancel() shakeSensorUnregisterTask?.cancel()
Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}") Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}")
var success = mSensorManager!!.registerListener( val success = mSensorManager!!.registerListener(
mShakeDetector, mShakeDetector,
mAccelerometer, mAccelerometer,
SensorManager.SENSOR_DELAY_UI SensorManager.SENSOR_DELAY_UI
@@ -1,7 +1,7 @@
package com.audiobookshelf.app.player package com.audiobookshelf.app.player
import android.os.Handler import android.content.Context
import android.os.Looper import android.os.*
import android.util.Log import android.util.Log
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
@@ -9,9 +9,8 @@ import kotlin.math.roundToInt
const val SLEEP_EXTENSION_TIME = 900000L // 15m const val SLEEP_EXTENSION_TIME = 900000L // 15m
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) { class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
private val tag = "SleepTimerManager" private val tag = "SleepTimerManager"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private var sleepTimerTask:TimerTask? = null private var sleepTimerTask:TimerTask? = null
private var sleepTimerRunning:Boolean = false private var sleepTimerRunning:Boolean = false
@@ -64,7 +63,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
// Register shake sensor // Register shake sensor
playerNotificationService.registerSensor() playerNotificationService.registerSensor()
var currentTime = getCurrentTime() val currentTime = getCurrentTime()
if (isChapterTime) { if (isChapterTime) {
if (currentTime > time) { if (currentTime > time) {
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time") Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
@@ -95,7 +94,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (getIsPlaying()) { if (getIsPlaying()) {
sleepTimerElapsed += 1000L sleepTimerElapsed += 1000L
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds() val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s") Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
if (sleepTimeSecondsRemaining > 0) { if (sleepTimeSecondsRemaining > 0) {
@@ -111,7 +110,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerFinishedAt = System.currentTimeMillis() sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) { } else if (sleepTimeSecondsRemaining <= 30) {
// Start fading out audio // Start fading out audio
var volume = sleepTimeSecondsRemaining / 30F val volume = sleepTimeSecondsRemaining / 30F
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining") Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume) setVolume(volume)
} }
@@ -129,7 +128,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
playerNotificationService.unregisterSensor() playerNotificationService.unregisterSensor()
} }
fun getSleepTimerTime():Long? { fun getSleepTimerTime():Long {
return sleepTimerEndTime return sleepTimerEndTime
} }
@@ -139,6 +138,30 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0) playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
} }
// Vibrate when extending sleep timer by shaking
private fun vibrate() {
val context = playerNotificationService.getContext()
val vibrator:Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibrator = vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
vibrator.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
it.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
it.vibrate(10)
}
}
}
private fun extendSleepTime() { private fun extendSleepTime() {
if (!sleepTimerRunning) return if (!sleepTimerRunning) return
setVolume(1F) setVolume(1F)
@@ -157,7 +180,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (!sleepTimerRunning) { if (!sleepTimerRunning) {
if (sleepTimerFinishedAt <= 0L) return if (sleepTimerFinishedAt <= 0L) return
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
{ {
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it") Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
@@ -165,14 +188,18 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
return return
} }
var newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
vibrate()
setSleepTimer(newSleepTime, false) setSleepTimer(newSleepTime, false)
play() play()
return return
} }
// Only extend if within 30 seconds of finishing // Only extend if within 30 seconds of finishing
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds() val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
if (sleepTimeRemaining <= 30) extendSleepTime() if (sleepTimeRemaining <= 30) {
vibrate()
extendSleepTime()
}
} }
fun handleShake() { fun handleShake() {
@@ -188,7 +215,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerLength += time sleepTimerLength += time
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime() if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else { } else {
var newSleepEndTime = sleepTimerEndTime + time val newSleepEndTime = sleepTimerEndTime + time
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) { sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
getDuration() getDuration()
} else { } else {
@@ -209,7 +236,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerLength -= time sleepTimerLength -= time
if (sleepTimerLength <= 0) sleepTimerLength = 1000L if (sleepTimerLength <= 0) sleepTimerLength = 1000L
} else { } else {
var newSleepEndTime = sleepTimerEndTime - time val newSleepEndTime = sleepTimerEndTime - time
sleepTimerEndTime = if (newSleepEndTime <= 1000) { sleepTimerEndTime = if (newSleepEndTime <= 1000) {
// End sleep timer in 1 second // End sleep timer in 1 second
getCurrentTime() + 1000 getCurrentTime() + 1000
@@ -16,6 +16,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.* import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import com.google.android.gms.cast.CastDevice import com.google.android.gms.cast.CastDevice
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import org.json.JSONObject import org.json.JSONObject
@CapacitorPlugin(name = "AbsAudioPlayer") @CapacitorPlugin(name = "AbsAudioPlayer")
@@ -23,9 +25,9 @@ class AbsAudioPlayer : Plugin() {
private val tag = "AbsAudioPlayer" private val tag = "AbsAudioPlayer"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity private lateinit var mainActivity: MainActivity
lateinit var apiHandler:ApiHandler private lateinit var apiHandler:ApiHandler
lateinit var castManager:CastManager var castManager:CastManager? = null
lateinit var playerNotificationService: PlayerNotificationService lateinit var playerNotificationService: PlayerNotificationService
@@ -37,7 +39,7 @@ class AbsAudioPlayer : Plugin() {
initCastManager() initCastManager()
var foregroundServiceReady : () -> Unit = { val foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService playerNotificationService = mainActivity.foregroundService
playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter { playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter {
@@ -57,13 +59,6 @@ class AbsAudioPlayer : Plugin() {
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata))) notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
} }
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("playWhenReady", playWhenReady)
notifyListeners("onPrepareMedia", jsobj)
}
override fun onSleepTimerEnded(currentPosition: Long) { override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition) emit("onSleepTimerEnded", currentPosition)
} }
@@ -83,19 +78,41 @@ class AbsAudioPlayer : Plugin() {
override fun onMediaPlayerChanged(mediaPlayer:String) { override fun onMediaPlayerChanged(mediaPlayer:String) {
emit("onMediaPlayerChanged", mediaPlayer) emit("onMediaPlayerChanged", mediaPlayer)
} }
override fun onProgressSyncFailing() {
emit("onProgressSyncFailing", "")
}
}) })
} }
mainActivity.pluginCallback = foregroundServiceReady mainActivity.pluginCallback = foregroundServiceReady
} }
fun emit(evtName: String, value: Any) { fun emit(evtName: String, value: Any) {
var ret = JSObject() val ret = JSObject()
ret.put("value", value) ret.put("value", value)
notifyListeners(evtName, ret) notifyListeners(evtName, ret)
} }
fun initCastManager() { private fun initCastManager() {
var connListener = object: CastManager.ChromecastListener() { val googleApi = GoogleApiAvailability.getInstance()
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
if (statusCode != ConnectionResult.SUCCESS) {
if (statusCode == ConnectionResult.SERVICE_MISSING) {
Log.w(tag, "initCastManager: Google Api Missing")
} else if (statusCode == ConnectionResult.SERVICE_DISABLED) {
Log.w(tag, "initCastManager: Google Api Disabled")
} else if (statusCode == ConnectionResult.SERVICE_INVALID) {
Log.w(tag, "initCastManager: Google Api Invalid")
} else if (statusCode == ConnectionResult.SERVICE_UPDATING) {
Log.w(tag, "initCastManager: Google Api Updating")
} else if (statusCode == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) {
Log.w(tag, "initCastManager: Google Api Update Required")
}
return
}
val connListener = object: CastManager.ChromecastListener() {
override fun onReceiverAvailableUpdate(available: Boolean) { override fun onReceiverAvailableUpdate(available: Boolean) {
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available") Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
isCastAvailable = available isCastAvailable = available
@@ -128,7 +145,7 @@ class AbsAudioPlayer : Plugin() {
} }
castManager = CastManager(mainActivity) castManager = CastManager(mainActivity)
castManager.startRouteScan(connListener) castManager?.startRouteScan(connListener)
} }
@PluginMethod @PluginMethod
@@ -141,9 +158,10 @@ class AbsAudioPlayer : Plugin() {
} }
} }
var libraryItemId = call.getString("libraryItemId", "").toString() val libraryItemId = call.getString("libraryItemId", "").toString()
var episodeId = call.getString("episodeId", "").toString() val episodeId = call.getString("episodeId", "").toString()
var playWhenReady = call.getBoolean("playWhenReady") == true val playWhenReady = call.getBoolean("playWhenReady") == true
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
if (libraryItemId.isEmpty()) { if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id") Log.e(tag, "Invalid call to play library item no library item id")
@@ -153,8 +171,8 @@ class AbsAudioPlayer : Plugin() {
if (libraryItemId.startsWith("local")) { // Play local media item if (libraryItemId.startsWith("local")) { // Play local media item
DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let { DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let {
var episode: PodcastEpisode? = null var episode: PodcastEpisode? = null
if (!episodeId.isNullOrEmpty()) { if (episodeId.isNotEmpty()) {
var podcastMedia = it.media as Podcast val podcastMedia = it.media as Podcast
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId } episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
if (episode == null) { if (episode == null) {
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId") Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
@@ -162,21 +180,21 @@ class AbsAudioPlayer : Plugin() {
} }
} }
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
var playbackSession = it.getPlaybackSession(episode) val playbackSession = it.getPlaybackSession(episode)
playerNotificationService.preparePlayer(playbackSession, playWhenReady) playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
} }
return call.resolve(JSObject()) return call.resolve(JSObject())
} }
} else { // Play library item from server } else { // Play library item from server
var mediaPlayer = playerNotificationService.getMediaPlayer() val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
playerNotificationService.preparePlayer(it, playWhenReady) playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
} }
call.resolve(JSObject(jacksonMapper.writeValueAsString(it))) call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
@@ -186,9 +204,9 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun getCurrentTime(call: PluginCall) { fun getCurrentTime(call: PluginCall) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
var currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = playerNotificationService.getCurrentTimeSeconds()
var bufferedTime = playerNotificationService.getBufferedTimeSeconds() val bufferedTime = playerNotificationService.getBufferedTimeSeconds()
val ret = JSObject() val ret = JSObject()
ret.put("value", currentTime) ret.put("value", currentTime)
ret.put("bufferedTime", bufferedTime) ret.put("bufferedTime", bufferedTime)
@@ -198,7 +216,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun pausePlayer(call: PluginCall) { fun pausePlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.pause() playerNotificationService.pause()
call.resolve() call.resolve()
} }
@@ -206,7 +224,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun playPlayer(call: PluginCall) { fun playPlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.play() playerNotificationService.play()
call.resolve() call.resolve()
} }
@@ -214,16 +232,16 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun playPause(call: PluginCall) { fun playPause(call: PluginCall) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
var playing = playerNotificationService.playPause() val playing = playerNotificationService.playPause()
call.resolve(JSObject("{\"playing\":$playing}")) call.resolve(JSObject("{\"playing\":$playing}"))
} }
} }
@PluginMethod @PluginMethod
fun seek(call: PluginCall) { fun seek(call: PluginCall) {
var time:Int = call.getInt("value", 0) ?: 0 // Value in seconds val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.seekPlayer(time * 1000L) // convert to ms playerNotificationService.seekPlayer(time * 1000L) // convert to ms
call.resolve() call.resolve()
} }
@@ -231,8 +249,8 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun seekForward(call: PluginCall) { fun seekForward(call: PluginCall) {
var amount:Int = call.getInt("value", 0) ?: 0 val amount:Int = call.getInt("value", 0) ?: 0
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.seekForward(amount * 1000L) // convert to ms playerNotificationService.seekForward(amount * 1000L) // convert to ms
call.resolve() call.resolve()
} }
@@ -240,8 +258,8 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun seekBackward(call: PluginCall) { fun seekBackward(call: PluginCall) {
var amount:Int = call.getInt("value", 0) ?: 0 // Value in seconds val amount:Int = call.getInt("value", 0) ?: 0 // Value in seconds
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.seekBackward(amount * 1000L) // convert to ms playerNotificationService.seekBackward(amount * 1000L) // convert to ms
call.resolve() call.resolve()
} }
@@ -249,9 +267,9 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun setPlaybackSpeed(call: PluginCall) { fun setPlaybackSpeed(call: PluginCall) {
var playbackSpeed:Float = call.getFloat("value", 1.0f) ?: 1.0f val playbackSpeed:Float = call.getFloat("value", 1.0f) ?: 1.0f
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.setPlaybackSpeed(playbackSpeed) playerNotificationService.setPlaybackSpeed(playbackSpeed)
call.resolve() call.resolve()
} }
@@ -259,7 +277,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun closePlayback(call: PluginCall) { fun closePlayback(call: PluginCall) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.closePlayback() playerNotificationService.closePlayback()
call.resolve() call.resolve()
} }
@@ -267,11 +285,11 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun setSleepTimer(call: PluginCall) { fun setSleepTimer(call: PluginCall) {
var time:Long = call.getString("time", "360000")!!.toLong() val time:Long = call.getString("time", "360000")!!.toLong()
var isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
var success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime) val success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
val ret = JSObject() val ret = JSObject()
ret.put("success", success) ret.put("success", success)
call.resolve(ret) call.resolve(ret)
@@ -280,7 +298,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun getSleepTimerTime(call: PluginCall) { fun getSleepTimerTime(call: PluginCall) {
var time = playerNotificationService.sleepTimerManager.getSleepTimerTime() val time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
val ret = JSObject() val ret = JSObject()
ret.put("value", time) ret.put("value", time)
call.resolve(ret) call.resolve(ret)
@@ -288,9 +306,9 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun increaseSleepTime(call: PluginCall) { fun increaseSleepTime(call: PluginCall) {
var time:Long = call.getString("time", "300000")!!.toLong() val time:Long = call.getString("time", "300000")!!.toLong()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.sleepTimerManager.increaseSleepTime(time) playerNotificationService.sleepTimerManager.increaseSleepTime(time)
val ret = JSObject() val ret = JSObject()
ret.put("success", true) ret.put("success", true)
@@ -300,9 +318,9 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun decreaseSleepTime(call: PluginCall) { fun decreaseSleepTime(call: PluginCall) {
var time:Long = call.getString("time", "300000")!!.toLong() val time:Long = call.getString("time", "300000")!!.toLong()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.sleepTimerManager.decreaseSleepTime(time) playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
val ret = JSObject() val ret = JSObject()
ret.put("success", true) ret.put("success", true)
@@ -321,7 +339,11 @@ class AbsAudioPlayer : Plugin() {
// Need to make sure the player service has been started // Need to make sure the player service has been started
Log.d(tag, "CAST REQUEST SESSION PLUGIN") Log.d(tag, "CAST REQUEST SESSION PLUGIN")
call.resolve() call.resolve()
castManager.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() { if (castManager == null) {
Log.e(tag, "Cast Manager not initialized")
return
}
castManager?.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
override fun onError(errorCode: Int) { override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode") Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
} }
@@ -338,7 +360,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun getIsCastAvailable(call: PluginCall) { fun getIsCastAvailable(call: PluginCall) {
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("value", isCastAvailable) jsobj.put("value", isCastAvailable)
call.resolve(jsobj) call.resolve(jsobj)
} }
@@ -6,6 +6,7 @@ import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.* import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -188,7 +189,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun removeLocalMediaProgress(call:PluginCall) { fun removeLocalMediaProgress(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString() val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId) DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve() call.resolve()
} }
@@ -204,47 +205,152 @@ class AbsDatabase : Plugin() {
} }
} }
// Updates received via web socket
// This function doesn't need to sync with the server also because this data is coming from the server
// If sending the localMediaProgressId then update existing media progress
// If sending localLibraryItemId then save new local media progress
@PluginMethod
fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) {
val serverMediaProgress = call.getObject("mediaProgress").toString()
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
var localMediaProgressId = call.getString("localMediaProgressId") ?: ""
val mediaProgress = jacksonMapper.readValue<MediaProgress>(serverMediaProgress)
if (localMediaProgressId == "") {
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem != null) {
localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = mediaProgress.episodeId)
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
} else {
Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found")
}
} else {
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId")
val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
call.resolve()
} else {
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
}
}
@PluginMethod @PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) { fun updateLocalMediaProgressFinished(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString() val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
var isFinished = call.getBoolean("isFinished", false) == true var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
val localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val isFinished = call.getBoolean("isFinished", false) == true
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished") Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.e(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId") if (localMediaProgress == null) { // Create new local media progress if does not exist
call.resolve(JSObject("{\"error\":\"Progress not found\"}")) Log.d(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId - Creating new")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
}
if (localLibraryItem.mediaType != "podcast" && !localEpisodeId.isNullOrEmpty()) {
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
}
var duration = 0.0
var podcastEpisode:PodcastEpisode? = null
if (!localEpisodeId.isNullOrEmpty()) {
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { episode ->
episode.id == localEpisodeId
}
if (podcastEpisode == null) {
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
}
duration = podcastEpisode.duration ?: 0.0
} else {
val book = localLibraryItem.media as Book
duration = book.duration ?: 0.0
}
val currentTime = System.currentTimeMillis()
localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = duration,
progress = if (isFinished) 1.0 else 0.0,
currentTime = 0.0,
isFinished = isFinished,
lastUpdate = currentTime,
startedAt = if (isFinished) currentTime else 0L,
finishedAt = if (isFinished) currentTime else null,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = podcastEpisode?.serverEpisodeId)
} else { } else {
localMediaProgress.updateIsFinished(isFinished) localMediaProgress.updateIsFinished(isFinished)
}
var lmpstring = jacksonMapper.writeValueAsString(localMediaProgress) // Save local media progress locally
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring") DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
// Send update to server media progress is linked to a server and user is logged into that server val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
localMediaProgress.serverConnectionConfigId?.let { configId -> Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: "" // Send update to server media progress is linked to a server and user is logged into that server
var episodeId = localMediaProgress.episodeId ?: "" localMediaProgress.serverConnectionConfigId?.let { configId ->
var updatePayload = JSObject() if (DeviceManager.serverConnectionConfigId == configId) {
updatePayload.put("isFinished", isFinished) var libraryItemId = localMediaProgress.libraryItemId ?: ""
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) { var episodeId = localMediaProgress.episodeId ?: ""
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server") var updatePayload = JSObject()
var jsobj = JSObject() updatePayload.put("isFinished", isFinished)
jsobj.put("local", true) apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
jsobj.put("server", true) Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
jsobj.put("localMediaProgress", JSObject(lmpstring)) var jsobj = JSObject()
call.resolve(jsobj) jsobj.put("local", true)
// call.resolve(JSObject("{\"local\":true,\"server\":true,\"localMediaProgress\":$lmpstring}")) jsobj.put("server", true)
} jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
} }
} }
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) { }
// call.resolve(JSObject("{\"local\":true,\"localMediaProgress\":$lmpstring}}")) if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
var jsobj = JSObject() var jsobj = JSObject()
jsobj.put("local", true) jsobj.put("local", true)
jsobj.put("server", false) jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring)) jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj) call.resolve(jsobj)
}
} }
} }
@@ -288,38 +394,4 @@ class AbsDatabase : Plugin() {
call.resolve() call.resolve()
} }
} }
//
// Generic Webview calls to db
//
@PluginMethod
fun saveFromWebview(call: PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
var value = call.getObject("value")
GlobalScope.launch(Dispatchers.IO) {
if (db == "" || key == "" || value == null) {
Log.d(tag, "saveFromWebview Invalid key/value")
} else {
var json = value as JSONObject
DeviceManager.dbManager.saveObject(db, key, json)
}
call.resolve()
}
}
@PluginMethod
fun loadFromWebview(call:PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
if (db == "" || key == "") {
Log.d(tag, "loadFromWebview Invalid Key")
call.resolve()
return
}
var json = DeviceManager.dbManager.loadObject(db, key)
var jsobj = JSObject.fromJSONObject(json)
call.resolve(jsobj)
}
} }
@@ -4,7 +4,12 @@ import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.*
import com.anggrayudi.storage.media.FileDescription
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -37,19 +42,54 @@ class AbsDownloader : Plugin() {
data class DownloadItemPart( data class DownloadItemPart(
val id: String, val id: String,
val filename: String, val filename: String,
val destinationPath:String, val finalDestinationPath:String,
val itemTitle: String, val itemTitle: String,
val serverPath: String, val serverPath: String,
val localFolderName: String, val localFolderName: String,
val localFolderUrl: String,
val localFolderId: String, val localFolderId: String,
val audioTrack: AudioTrack?, val audioTrack: AudioTrack?,
val episode:PodcastEpisode?, val episode:PodcastEpisode?,
var completed:Boolean, var completed:Boolean,
var moved:Boolean,
var failed:Boolean,
@JsonIgnore val uri: Uri, @JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri, @JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
var downloadId: Long?, var downloadId: Long?,
var progress: Long var progress: Long
) { ) {
companion object {
fun make(filename:String, destinationFile:File, finalDestinationFile:File, itemTitle:String, serverPath:String, localFolder:LocalFolder, audioTrack:AudioTrack?, episode:PodcastEpisode?) :DownloadItemPart {
val destinationUri = Uri.fromFile(destinationFile)
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
val downloadUri = Uri.parse(downloadUrl)
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
return DownloadItemPart(
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
filename = filename, finalDestinationFile.absolutePath,
itemTitle = itemTitle,
serverPath = serverPath,
localFolderName = localFolder.name,
localFolderUrl = localFolder.contentUrl,
localFolderId = localFolder.id,
audioTrack = audioTrack,
episode = episode,
completed = false,
moved = false,
failed = false,
uri = downloadUri,
destinationUri = destinationUri,
finalDestinationUri = finalDestinationUri,
downloadId = null,
progress = 0
)
}
}
@JsonIgnore @JsonIgnore
fun getDownloadRequest(): DownloadManager.Request { fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri) val dlRequest = DownloadManager.Request(uri)
@@ -65,6 +105,7 @@ class AbsDownloader : Plugin() {
val id: String, val id: String,
val libraryItemId:String, val libraryItemId:String,
val episodeId:String?, val episodeId:String?,
val userMediaProgress:MediaProgress?,
val serverConnectionConfigId:String, val serverConnectionConfigId:String,
val serverAddress:String, val serverAddress:String,
val serverUserId:String, val serverUserId:String,
@@ -92,27 +133,27 @@ class AbsDownloader : Plugin() {
val libraryItemId = call.data.getString("libraryItemId").toString() val libraryItemId = call.data.getString("libraryItemId").toString()
var episodeId = call.data.getString("episodeId").toString() var episodeId = call.data.getString("episodeId").toString()
if (episodeId == "null") episodeId = "" if (episodeId == "null") episodeId = ""
var localFolderId = call.data.getString("localFolderId").toString() val localFolderId = call.data.getString("localFolderId").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
var downloadId = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId" val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadQueue.find { it.id == downloadId } != null) { if (downloadQueue.find { it.id == downloadId } != null) {
Log.d(tag, "Download already started for this media entity $downloadId") Log.d(tag, "Download already started for this media entity $downloadId")
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}")) return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
} }
apiHandler.getLibraryItem(libraryItemId) { libraryItem -> apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
Log.d(tag, "Got library item from server ${libraryItem.id}") Log.d(tag, "Got library item from server ${libraryItem.id}")
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) { if (localFolder != null) {
if (!episodeId.isNullOrEmpty() && libraryItem.mediaType != "podcast") { if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested") Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (!episodeId.isNullOrEmpty()) { } else if (episodeId.isNotEmpty()) {
var podcast = libraryItem.media as Podcast val podcast = libraryItem.media as Podcast
var episode = podcast.episodes?.find { podcastEpisode -> val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId podcastEpisode.id == episodeId
} }
if (episode == null) { if (episode == null) {
@@ -132,104 +173,111 @@ class AbsDownloader : Plugin() {
} }
// Clean folder path so it can be used in URL // Clean folder path so it can be used in URL
fun cleanRelPath(relPath: String): String { private fun cleanRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23") val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
} }
// Item filenames could be the same if they are in subfolders, this will make them unique // Item filenames could be the same if they are in sub-folders, this will make them unique
fun getFilenameFromRelPath(relPath: String): String { private fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
cleanedRelPath = cleanStringForFileSystem(cleanedRelPath)
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
} }
fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { // Replace characters that cant be used in the file system
// Reserved characters: ?:\"*|/\\<>
private fun cleanStringForFileSystem(str:String):String {
val reservedCharacters = listOf("?", "\"", "*", "|", "/", "\\", "<", ">")
var newTitle = str
newTitle = newTitle.replace(":", " -") // Special case replace : with -
reservedCharacters.forEach {
newTitle = newTitle.replace(it, "")
}
return newTitle
}
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
if (libraryItem.mediaType == "book") { if (libraryItem.mediaType == "book") {
var bookTitle = libraryItem.media.metadata.title val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
var tracks = libraryItem.media.getAudioTracks()
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks") Log.d(tag, "Starting library item download with ${tracks.size} tracks")
var itemFolderPath = localFolder.absolutePath + "/" + bookTitle val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
var downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
// Create download item part for each audio track // Create download item part for each audio track
tracks.forEach { audioTrack -> tracks.forEach { audioTrack ->
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}" val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
var destinationFilename = getFilenameFromRelPath(audioTrack.relPath) val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
var destinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) { val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}") val destinationFile = File("$tempFolderPath/$destinationFilename")
destinationFile.delete()
if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
} }
var destinationUri = Uri.fromFile(destinationFile) val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,audioTrack,null)
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, audioTrack, null, false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest() val dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest) val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId downloadItemPart.downloadId = downloadId
} }
if (downloadItem.downloadItemParts.isNotEmpty()) { if (downloadItem.downloadItemParts.isNotEmpty()) {
// Add cover download item // Add cover download item
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg" val serverPath = "/api/items/${libraryItem.id}/cover"
var destinationFilename = "cover.jpg" val destinationFilename = "cover.jpg"
var destinationFile = File("$itemFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Cover already exists, removing it from ${destinationFile.absolutePath}") Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
destinationFile.delete() finalDestinationFile.delete()
} }
var destinationUri = Uri.fromFile(destinationFile) val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,null,null)
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}")
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest() val dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest) val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId downloadItemPart.downloadId = downloadId
} }
// TODO: Cannot create new text file here but can download here... ??
// var abmetadataFile = File(itemFolderPath, "abmetadata.abs")
// abmetadataFile.createNewFileIfPossible()
// abmetadataFile.writeText(getAbMetadataText(libraryItem))
downloadQueue.add(downloadItem) downloadQueue.add(downloadItem)
startWatchingDownloads(downloadItem) startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem) DeviceManager.dbManager.saveDownloadItem(downloadItem)
} }
} else { } else {
// Podcast episode download // Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
var podcastTitle = libraryItem.media.metadata.title val audioTrack = episode?.audioTrack
var audioTrack = episode?.audioTrack
Log.d(tag, "Starting podcast episode download") Log.d(tag, "Starting podcast episode download")
var itemFolderPath = localFolder.absolutePath + "/" + podcastTitle val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
var downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItemId = "${libraryItem.id}-${episode?.id}"
var downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}" var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
var destinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) { var destinationFile = File("$tempFolderPath/$destinationFilename")
Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}") var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
destinationFile.delete() if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
} }
var destinationUri = Uri.fromFile(destinationFile) var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, audioTrack, episode,false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest() var dlRequest = downloadItemPart.getDownloadRequest()
@@ -237,17 +285,16 @@ class AbsDownloader : Plugin() {
downloadItemPart.downloadId = downloadId downloadItemPart.downloadId = downloadId
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg" serverPath = "/api/items/${libraryItem.id}/cover"
destinationFilename = "cover.jpg" destinationFilename = "cover.jpg"
destinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) { destinationFile = File("$tempFolderPath/$destinationFilename")
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again") Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else { } else {
destinationUri = Uri.fromFile(destinationFile) downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}")
downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
dlRequest = downloadItemPart.getDownloadRequest() dlRequest = downloadItemPart.getDownloadRequest()
@@ -262,10 +309,10 @@ class AbsDownloader : Plugin() {
} }
} }
fun startWatchingDownloads(downloadItem: DownloadItem) { private fun startWatchingDownloads(downloadItem: DownloadItem) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
while (downloadItem.downloadItemParts.find { !it.completed } != null) { // While some item is not completed while (downloadItem.downloadItemParts.find { !it.moved && !it.failed } != null) { // While some item is not completed
var numPartsBefore = downloadItem.downloadItemParts.size val numPartsBefore = downloadItem.downloadItemParts.size
checkDownloads(downloadItem) checkDownloads(downloadItem)
// Keep database updated as item parts finish downloading // Keep database updated as item parts finish downloading
@@ -278,43 +325,87 @@ class AbsDownloader : Plugin() {
delay(500) delay(500)
} }
var localLibraryItem = folderScanner.scanDownloadItem(downloadItem) // Remove download notifications
downloadItem.downloadItemParts.forEach { downloadItemPart ->
downloadItemPart.downloadId?.let {
downloadManager.remove(it)
}
}
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id) DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
downloadQueue.remove(downloadItem) downloadQueue.remove(downloadItem)
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}") Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("libraryItemId", downloadItem.id) jsobj.put("libraryItemId", downloadItem.id)
jsobj.put("localFolderId", downloadItem.localFolder.id) jsobj.put("localFolderId", downloadItem.localFolder.id)
if (localLibraryItem != null) {
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
} }
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
notifyListeners("onItemDownloadComplete", jsobj) notifyListeners("onItemDownloadComplete", jsobj)
} }
} }
fun checkDownloads(downloadItem: DownloadItem) { private fun checkDownloads(downloadItem: DownloadItem) {
var itemParts = downloadItem.downloadItemParts.map { it } val itemParts = downloadItem.downloadItemParts.map { it }
for (downloadItemPart in itemParts) { for (downloadItemPart in itemParts) {
if (downloadItemPart.downloadId != null) { if (downloadItemPart.downloadId != null) {
var dlid = downloadItemPart.downloadId!! val dlid = downloadItemPart.downloadId!!
val query = DownloadManager.Query().setFilterById(dlid) val query = DownloadManager.Query().setFilterById(dlid)
downloadManager.query(query).use { downloadManager.query(query).use {
if (it.moveToFirst()) { if (it.moveToFirst()) {
val totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS)) val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getInt(bytesDownloadedColumnIndex) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus") Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done") // Once file download is complete move the file to the final destination
// downloadItem.downloadItemParts.remove(downloadItemPart) if (!downloadItemPart.completed) {
downloadItemPart.completed = true Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
downloadItemPart.completed = true
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}")
val fcb = object : FileCallback() {
override fun onPrepare() {
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
}
override fun onFailed(errorCode:ErrorCode) {
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
downloadItemPart.failed = true
file?.delete()
}
override fun onCompleted(result:Any) {
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
val resultDocFile = result as DocumentFile
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO ${resultDocFile.getAbsolutePath(mainActivity)}")
downloadItemPart.moved = true
}
}
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb)
} else {
// Why is kotlin requiring an else here..
}
} else if (downloadStatus == DownloadManager.STATUS_FAILED) { } else if (downloadStatus == DownloadManager.STATUS_FAILED) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed") Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItem.downloadItemParts.remove(downloadItemPart) downloadItem.downloadItemParts.remove(downloadItemPart)
// downloadItemPart.completed = true
} else { } else {
//update progress //update progress
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0 val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
@@ -19,11 +19,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiHandler(var ctx:Context) { class ApiHandler(var ctx:Context) {
val tag = "ApiHandler" val tag = "ApiHandler"
private var client = OkHttpClient() private var defaultClient = OkHttpClient()
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var storageSharedPreferences: SharedPreferences? = null var storageSharedPreferences: SharedPreferences? = null
@@ -33,11 +35,14 @@ class ApiHandler(var ctx:Context) {
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>) data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int) data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) { fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
val request = Request.Builder() val request = Request.Builder()
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
.build() .build()
makeRequest(request, cb) makeRequest(request, httpClient, cb)
} }
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
@@ -46,7 +51,7 @@ class ApiHandler(var ctx:Context) {
val request = Request.Builder().post(requestBody) val request = Request.Builder().post(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build() .build()
makeRequest(request, cb) makeRequest(request, null, cb)
} }
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
@@ -55,7 +60,7 @@ class ApiHandler(var ctx:Context) {
val request = Request.Builder().patch(requestBody) val request = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build() .build()
makeRequest(request, cb) makeRequest(request, null, cb)
} }
fun isOnline(): Boolean { fun isOnline(): Boolean {
@@ -76,7 +81,8 @@ class ApiHandler(var ctx:Context) {
return false return false
} }
fun makeRequest(request:Request, cb: (JSObject) -> Unit) { fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
val client = httpClient ?: defaultClient
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT") Log.d(tag, "FAILURE TO CONNECT")
@@ -86,15 +92,21 @@ class ApiHandler(var ctx:Context) {
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
if (!it.isSuccessful) throw IOException("Unexpected code $response") if (!it.isSuccessful) {
// throw IOException("Unexpected code $response")
val jsobj = JSObject()
jsobj.put("error", "Unexpected code $response")
cb(jsobj)
return
}
var bodyString = it.body!!.string() val bodyString = it.body!!.string()
if (bodyString == "OK") { if (bodyString == "OK") {
cb(JSObject()) cb(JSObject())
} else { } else {
var jsonObj = JSObject() var jsonObj = JSObject()
if (bodyString.startsWith("[")) { if (bodyString.startsWith("[")) {
var array = JSArray(bodyString) val array = JSArray(bodyString)
jsonObj.put("value", array) jsonObj.put("value", array)
} else { } else {
jsonObj = JSObject(bodyString) jsonObj = JSObject(bodyString)
@@ -108,10 +120,10 @@ class ApiHandler(var ctx:Context) {
fun getLibraries(cb: (List<Library>) -> Unit) { fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper val mapper = jacksonMapper
getRequest("/api/libraries") { getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>() val libraries = mutableListOf<Library>()
if (it.has("value")) { if (it.has("value")) {
var array = it.getJSONArray("value") val array = it.getJSONArray("value")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val library = mapper.readValue<Library>(array.get(i).toString()) val library = mapper.readValue<Library>(array.get(i).toString())
libraries.add(library) libraries.add(library)
@@ -122,17 +134,26 @@ class ApiHandler(var ctx:Context) {
} }
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) { fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") { getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl, null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString()) val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem) cb(libraryItem)
} }
} }
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) { fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") { getRequest("/api/libraries/$libraryId/items?limit=100&minified=1", null, null) {
val items = mutableListOf<LibraryItem>() val items = mutableListOf<LibraryItem>()
if (it.has("results")) { if (it.has("results")) {
var array = it.getJSONArray("results") val array = it.getJSONArray("results")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString()) val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
items.add(item) items.add(item)
@@ -143,14 +164,14 @@ class ApiHandler(var ctx:Context) {
} }
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized") { getRequest("/api/libraries/$libraryId/personalized", null, null) {
val items = mutableListOf<LibraryCategory>() val items = mutableListOf<LibraryCategory>()
if (it.has("value")) { if (it.has("value")) {
var array = it.getJSONArray("value") val array = it.getJSONArray("value")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
var jsobj = array.get(i) as JSONObject val jsobj = array.get(i) as JSONObject
var type = jsobj.get("type").toString() val type = jsobj.get("type").toString()
// Only support for podcast and book in android auto // Only support for podcast and book in android auto
if (type == "podcast" || type == "book") { if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false) jsobj.put("isLocal", false)
@@ -163,13 +184,8 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession) -> Unit) {
var payload = JSObject() val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
payload.put("mediaPlayer", mediaPlayer)
// Only if direct play fails do we force transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId" val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
postRequest(endpoint, payload) { postRequest(endpoint, payload) {
@@ -180,16 +196,20 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) { fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
var payload = JSObject(jacksonMapper.writeValueAsString(syncData)) val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) { postRequest("/api/session/$sessionId/sync", payload) {
cb() if (!it.getString("error").isNullOrEmpty()) {
cb(false)
} else {
cb(true)
}
} }
} }
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) { fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
var payload = JSObject(jacksonMapper.writeValueAsString(playbackSession)) val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload) { postRequest("/api/session/local", payload) {
cb() cb()
@@ -204,15 +224,15 @@ class ApiHandler(var ctx:Context) {
} }
// Get all local media progress connected to items on the current connected server // Get all local media progress connected to items on the current connected server
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
} }
var localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0) val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
if (localMediaProgress.isNotEmpty()) { if (localMediaProgress.isNotEmpty()) {
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items") Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
var payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress))) val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
postRequest("/api/me/sync-local-progress", payload) { postRequest("/api/me/sync-local-progress", payload) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}") Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
@@ -242,10 +262,44 @@ class ApiHandler(var ctx:Context) {
fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) { fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) {
Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload") Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload")
var endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId" val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
patchRequest(endpoint,updatePayload) { patchRequest(endpoint,updatePayload) {
Log.d(tag, "updateMediaProgress patched progress") Log.d(tag, "updateMediaProgress patched progress")
cb() cb()
} }
} }
fun getMediaProgress(libraryItemId:String, episodeId:String?, cb: (MediaProgress) -> Unit) {
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
getRequest(endpoint, null, null) {
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
cb(progress)
}
}
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
val endpoint = "/api/session/$playbackSessionId"
getRequest(endpoint, null, null) {
val err = it.getString("error")
if (!err.isNullOrEmpty()) {
cb(null)
} else {
cb(jacksonMapper.readValue<PlaybackSession>(it.toString()))
}
}
}
fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) {
Log.d(tag, "pingServer: Pinging ${config.address}")
getRequest("/ping", pingClient, config) {
val success = it.getString("success")
if (success.isNullOrEmpty()) {
Log.d(tag, "pingServer: Ping ${config.address} Failed")
cb(false)
} else {
Log.d(tag, "pingServer: Ping ${config.address} Successful")
cb(true)
}
}
}
} }
@@ -10,9 +10,6 @@
android:translateY="-1.5294118"> android:translateY="-1.5294118">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/> android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,12l-2.5,-1.5L15,12L15,4h5v8z"/>
</group> </group>
</vector> </vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

-3
View File
@@ -19,6 +19,3 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
include ':capacitor-storage' include ':capacitor-storage'
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android') project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
include ':robingenz-capacitor-app-update'
project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android')
+4 -1
View File
@@ -16,6 +16,7 @@ body {
min-height: calc(100% - 64px); min-height: calc(100% - 64px);
max-height: calc(100% - 64px); max-height: calc(100% - 64px);
} }
#content.playerOpen { #content.playerOpen {
height: calc(100% - 164px); height: calc(100% - 164px);
min-height: calc(100% - 164px); min-height: calc(100% - 164px);
@@ -46,6 +47,7 @@ body {
.box-shadow-book { .box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
} }
.shadow-height { .shadow-height {
height: calc(100% - 4px); height: calc(100% - 4px);
} }
@@ -53,6 +55,7 @@ body {
.bookshelfRow { .bookshelfRow {
background-image: url(/wood_panels.jpg); background-image: url(/wood_panels.jpg);
} }
.bookshelfDivider { .bookshelfDivider {
background: rgb(149, 119, 90); background: rgb(149, 119, 90);
background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
@@ -63,9 +66,9 @@ body {
Bookshelf Label Bookshelf Label
*/ */
.categoryPlacard { .categoryPlacard {
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
letter-spacing: 1px; letter-spacing: 1px;
} }
.shinyBlack { .shinyBlack {
background-color: #2d3436; background-color: #2d3436;
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%); background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
+275 -40
View File
@@ -1,10 +1,10 @@
@font-face { @font-face {
font-family: 'Material Icons'; font-family: 'Material Icons';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(/fonts/MaterialIcons.woff2) format('woff2'); src: url(/fonts/MaterialIcons.woff2) format('woff2');
} }
@font-face { @font-face {
font-family: 'Material Icons Outlined'; font-family: 'Material Icons Outlined';
font-style: normal; font-style: normal;
@@ -12,43 +12,6 @@
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2'); src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
} }
/* .material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
line-height: 1;
font-size: 1.5rem;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons.text-icon {
font-size: 1.15rem;
}
.material-icons.text-lg {
font-size: 1.25rem;
}
.material-icons.text-2xl {
font-size: 1.5rem;
}
.material-icons.text-3xl {
font-size: 1.875rem;
}
.material-icons.text-4xl {
font-size: 2.25rem;
}
.material-icons.text-5xl {
font-size: 3rem;
}
.material-icons.text-base {
font-size: 1rem;
} */
.material-icons { .material-icons {
font-family: 'Material Icons'; font-family: 'Material Icons';
font-weight: normal; font-weight: normal;
@@ -60,9 +23,9 @@
white-space: nowrap; white-space: nowrap;
word-wrap: normal; word-wrap: normal;
direction: ltr; direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) { .material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -78,9 +41,9 @@
white-space: nowrap; white-space: nowrap;
word-wrap: normal; word-wrap: normal;
direction: ltr; direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) { .material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -93,6 +56,7 @@
src: url(/fonts/GentiumBookBasic.woff2) format('woff2'); src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Gentium Book Basic'; font-family: 'Gentium Book Basic';
@@ -102,3 +66,274 @@
src: url(/fonts/GentiumBookBasic.woff2) format('woff2'); src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* latin-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+5 -10
View File
@@ -7,7 +7,7 @@
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer"> <a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
<span class="material-icons text-3xl text-white">arrow_back</span> <span class="material-icons text-3xl text-white">arrow_back</span>
</a> </a>
<div v-if="user"> <div v-if="user && currentLibrary">
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal"> <div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" /> <widgets-library-icon :icon="currentLibraryIcon" :size="4" />
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p> <p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p>
@@ -18,8 +18,8 @@
<widgets-download-progress-indicator /> <widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server --> <!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer"> <div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.75rem" @click="castClick">cast</span> <span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span>
</div> </div>
<nuxt-link v-if="user" class="h-7 mx-2" to="/search"> <nuxt-link v-if="user" class="h-7 mx-2" to="/search">
@@ -51,14 +51,11 @@ export default {
this.$store.commit('setCastAvailable', val) this.$store.commit('setCastAvailable', val)
} }
}, },
socketConnected() {
return this.$store.state.socketConnected
},
currentLibrary() { currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary'] return this.$store.getters['libraries/getCurrentLibrary']
}, },
currentLibraryName() { currentLibraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'Main' return this.currentLibrary ? this.currentLibrary.name : ''
}, },
currentLibraryIcon() { currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon : 'database' return this.currentLibrary ? this.currentLibrary.icon : 'database'
@@ -80,11 +77,9 @@ export default {
methods: { methods: {
castClick() { castClick() {
if (this.$store.state.playerIsLocal) { if (this.$store.state.playerIsLocal) {
this.$toast.warn('Cannot cast downloaded media item') this.$eventBus.$emit('cast-local-item')
return return
} }
console.log('Cast Btn Click')
AbsAudioPlayer.requestSession() AbsAudioPlayer.requestSession()
}, },
clickShowSideDrawer() { clickShowSideDrawer() {
+96 -27
View File
@@ -1,10 +1,10 @@
<template> <template>
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''"> <div v-if="playbackSession" id="streamContainer" class="playerContainer fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto"> <div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
<div class="top-2 left-4 absolute cursor-pointer"> <div class="top-2 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span> <span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
</div> </div>
<div v-show="showCastBtn" class="top-3.5 right-20 absolute cursor-pointer"> <div v-show="showCastBtn" class="top-4 right-16 absolute cursor-pointer">
<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">
@@ -12,6 +12,7 @@
<span class="material-icons text-3xl">more_vert</span> <span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu> </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>
</div> </div>
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30"> <div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
@@ -40,10 +41,10 @@
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p> <p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
</div> </div>
<div id="streamContainer" class="w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer"> <div id="playerContent" class="playerContainer w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
<div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px"> <div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
<div class="flex items-center justify-between pointer-events-auto"> <div class="flex items-center justify-between pointer-events-auto">
<span v-if="!isPodcast" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span> <span v-if="!isPodcast && !isLocalPlayMethod" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
<!-- hidden for podcasts but still using this as a placeholder --> <!-- hidden for podcasts but still using this as a placeholder -->
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span> <span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
@@ -63,7 +64,7 @@
<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" 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="backward10">replay_10</span> <span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<div class="play-btn cursor-pointer shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4" :class="seekLoading ? 'animate-spin' : ''" @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>
@@ -73,12 +74,13 @@
</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' : ''" @click="clickTrack"> <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="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> </div>
<div 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>
<div class="flex-grow" /> <div class="flex-grow" />
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p> <p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
@@ -131,7 +133,9 @@ export default {
touchStartTime: 0, touchStartTime: 0,
touchEndY: 0, touchEndY: 0,
useChapterTrack: false, useChapterTrack: false,
isLoading: false isLoading: false,
touchTrackStart: false,
dragPercent: 0
} }
}, },
computed: { computed: {
@@ -159,7 +163,7 @@ export default {
return this.showFullscreen ? 200 : 60 return this.showFullscreen ? 200 : 60
}, },
showCastBtn() { showCastBtn() {
return this.$store.state.isCastAvailable && !this.isLocalPlayMethod return this.$store.state.isCastAvailable
}, },
isCasting() { isCasting() {
return this.mediaPlayer === 'cast-player' return this.mediaPlayer === 'cast-player'
@@ -193,6 +197,9 @@ export default {
isLocalPlayMethod() { isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL return this.playMethod == this.$constants.PlayMethod.LOCAL
}, },
isDirectPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
},
title() { title() {
if (this.playbackSession) return this.playbackSession.displayTitle if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title' return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
@@ -264,17 +271,19 @@ export default {
} }
}, },
methods: { methods: {
touchstartTrack(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
this.touchTrackStart = true
},
selectChapter(chapter) { selectChapter(chapter) {
this.seek(chapter.start) this.seek(chapter.start)
this.showChapterModal = false this.showChapterModal = false
}, },
castClick() { castClick() {
console.log('Cast Btn Click')
if (this.isLocalPlayMethod) { if (this.isLocalPlayMethod) {
this.$toast.warn('Cannot cast downloaded media items') this.$eventBus.$emit('cast-local-item')
return return
} }
AbsAudioPlayer.requestSession() AbsAudioPlayer.requestSession()
}, },
clickContainer() { clickContainer() {
@@ -515,15 +524,60 @@ export default {
this.touchStartTime = Date.now() this.touchStartTime = Date.now()
}, },
touchend(e) { touchend(e) {
if (!this.showFullscreen || !e.changedTouches) return if (!e.changedTouches) return
this.touchEndY = e.changedTouches[0].screenY if (this.touchTrackStart) {
var touchDuration = Date.now() - this.touchStartTime var touch = e.changedTouches[0]
if (touchDuration > 1200) { const touchOnTrackPos = touch.pageX - 12
// console.log('touch too long', touchDuration) const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
return
var seekToTime = 0
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
seekToTime = this.currentChapter.start + currChapTime
} else {
seekToTime = dragPercent * this.totalDuration
}
this.seek(seekToTime)
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = '0px'
}
this.touchTrackStart = false
} else if (this.showFullscreen) {
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
}
this.handleGesture()
}
},
touchmove(e) {
if (!this.touchTrackStart) return
var touch = e.touches[0]
const touchOnTrackPos = touch.pageX - 12
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
this.dragPercent = dragPercent
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = this.dragPercent * this.trackWidth + 'px'
}
var ts = this.$refs.currentTimestamp
if (ts) {
var currTimeStr = ''
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
currTimeStr = this.$secondsToTimestamp(currChapTime)
} else {
const dragTime = dragPercent * this.totalDuration
currTimeStr = this.$secondsToTimestamp(dragTime)
}
ts.innerText = currTimeStr
} }
this.handleGesture()
}, },
clickMenuAction(action) { clickMenuAction(action) {
if (action === 'chapter_track') { if (action === 'chapter_track') {
@@ -545,13 +599,15 @@ export default {
} }
}, },
closePlayback() { closePlayback() {
this.endPlayback()
AbsAudioPlayer.closePlayback()
},
endPlayback() {
this.$store.commit('setPlayerItem', null) this.$store.commit('setPlayerItem', null)
this.showFullscreen = false this.showFullscreen = false
this.isEnded = false this.isEnded = false
this.isLoading = false this.isLoading = false
this.playbackSession = null this.playbackSession = null
AbsAudioPlayer.closePlayback()
}, },
// //
// Listeners from audio AbsAudioPlayer // Listeners from audio AbsAudioPlayer
@@ -607,13 +663,13 @@ export default {
}, },
onPlaybackClosed() { onPlaybackClosed() {
console.log('Received onPlaybackClosed evt') console.log('Received onPlaybackClosed evt')
this.closePlayback() this.endPlayback()
}, },
onPlaybackFailed(data) { onPlaybackFailed(data) {
console.log('Received onPlaybackFailed evt') console.log('Received onPlaybackFailed evt')
var errorMessage = data.value || 'Unknown Error' var errorMessage = data.value || 'Unknown Error'
this.$toast.error(`Playback Failed: ${errorMessage}`) this.$toast.error(`Playback Failed: ${errorMessage}`)
this.closePlayback() this.endPlayback()
}, },
async init() { async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack() this.useChapterTrack = await this.$localStore.getUseChapterTrack()
@@ -628,13 +684,19 @@ export default {
mounted() { mounted() {
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend) document.body.addEventListener('touchend', this.touchend)
document.body.addEventListener('touchmove', this.touchmove)
this.$nextTick(this.init) this.$nextTick(this.init)
}, },
beforeDestroy() { beforeDestroy() {
if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback')
this.closePlayback()
}
this.forceCloseDropdownMenu() this.forceCloseDropdownMenu()
document.body.removeEventListener('touchstart', this.touchstart) document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove() if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove() if (this.onMetadataListener) this.onMetadataListener.remove()
@@ -650,13 +712,15 @@ export default {
.bookCoverWrapper { .bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066; box-shadow: 3px -2px 5px #00000066;
} }
#streamContainer { .playerContainer {
box-shadow: 0px -8px 8px #11111155;
height: 100px; height: 100px;
} }
.fullscreen #streamContainer { .fullscreen .playerContainer {
height: 200px; height: 200px;
} }
#playerContent {
box-shadow: 0px -8px 8px #11111155;
}
#playerTrack { #playerTrack {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
@@ -667,6 +731,11 @@ export default {
margin-bottom: 18px; margin-bottom: 18px;
} }
.ios-player #timestamp-row {
padding-left: 16px;
padding-right: 16px;
}
.cover-wrapper { .cover-wrapper {
bottom: 44px; bottom: 44px;
left: 12px; left: 12px;
+54 -5
View File
@@ -4,7 +4,7 @@
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" /> <modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="currentTime" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="currentTime" :library-item-id="serverLibraryItemId" @select="selectBookmark" />
</div> </div>
</template> </template>
@@ -30,8 +30,11 @@ export default {
onSleepTimerEndedListener: null, onSleepTimerEndedListener: null,
onSleepTimerSetListener: null, onSleepTimerSetListener: null,
onMediaPlayerChangedListener: null, onMediaPlayerChangedListener: null,
onProgressSyncFailing: null,
sleepInterval: null, sleepInterval: null,
currentEndOfChapterTime: 0 currentEndOfChapterTime: 0,
serverLibraryItemId: null,
syncFailedToast: null
} }
}, },
watch: { watch: {
@@ -44,8 +47,8 @@ export default {
}, },
computed: { computed: {
bookmarks() { bookmarks() {
// return this.$store.getters['user/getUserBookmarksForItem'](this.) if (!this.serverLibraryItemId) return []
return [] return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
}, },
socketConnected() { socketConnected() {
return this.$store.state.socketConnected return this.$store.state.socketConnected
@@ -167,10 +170,36 @@ export default {
this.$refs.audioPlayer.closePlayback() this.$refs.audioPlayer.closePlayback()
} }
}, },
castLocalItem() {
if (!this.serverLibraryItemId) {
this.$toast.error(`Cannot cast locally downloaded media`)
} else {
// Change to server library item
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
}
},
playServerLibraryItemAndCast(libraryItemId) {
var playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
})
.catch((error) => {
console.error('Failed', error)
})
},
async playLibraryItem(payload) { async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId var episodeId = payload.episodeId
// When playing local library item and can also play this item from the server
// then store the server library item id so it can be used if a cast is made
var serverLibraryItemId = payload.serverLibraryItemId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) { if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Warning', title: 'Warning',
@@ -181,10 +210,22 @@ export default {
} }
} }
this.serverLibraryItemId = null
var playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
console.log('Called playLibraryItem', libraryItemId) console.log('Called playLibraryItem', libraryItemId)
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true }) AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
.then((data) => { .then((data) => {
console.log('Library item play response', JSON.stringify(data)) console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
} else {
this.serverLibraryItemId = serverLibraryItemId
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
@@ -202,6 +243,10 @@ export default {
onMediaPlayerChanged(data) { onMediaPlayerChanged(data) {
var mediaPlayer = data.value var mediaPlayer = data.value
this.$store.commit('setMediaPlayer', mediaPlayer) this.$store.commit('setMediaPlayer', mediaPlayer)
},
showProgressSyncIsFailing() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced', { timeout: false, type: 'error' })
} }
}, },
mounted() { mounted() {
@@ -209,6 +254,7 @@ export default {
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
@@ -217,6 +263,7 @@ export default {
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)
this.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
}, },
beforeDestroy() { beforeDestroy() {
@@ -224,6 +271,7 @@ export default {
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
// if (this.$server.socket) { // if (this.$server.socket) {
// this.$server.socket.off('stream_open', this.streamOpen) // this.$server.socket.off('stream_open', this.streamOpen)
@@ -235,6 +283,7 @@ export default {
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)
this.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$store.commit('user/removeSettingsListener', 'streamContainer') this.$store.commit('user/removeSettingsListener', 'streamContainer')
} }
} }
+5 -3
View File
@@ -74,9 +74,6 @@ export default {
username() { username() {
return this.user ? this.user.username : '' return this.user ? this.user.username : ''
}, },
socketConnected() {
return this.$store.state.socketConnected
},
navItems() { navItems() {
var items = [ var items = [
{ {
@@ -108,6 +105,11 @@ export default {
text: 'Local Media', text: 'Local Media',
to: '/localMedia/folders' to: '/localMedia/folders'
}) })
// items.push({
// icon: 'settings',
// text: 'Settings',
// to: '/settings'
// })
} }
return items return items
}, },
+28 -8
View File
@@ -3,6 +3,7 @@
<template v-for="shelf in totalShelves"> <template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }"> <div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" /> <div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div v-else class="flex border-t border-white border-opacity-10" />
</div> </div>
</template> </template>
@@ -24,11 +25,12 @@ export default {
mixins: [bookshelfCardsHelpers], mixins: [bookshelfCardsHelpers],
data() { data() {
return { return {
routeFullPath: null,
entitiesPerShelf: 2,
bookshelfHeight: 0, bookshelfHeight: 0,
bookshelfWidth: 0, bookshelfWidth: 0,
bookshelfMarginLeft: 0, bookshelfMarginLeft: 0,
shelvesPerPage: 0, shelvesPerPage: 0,
entitiesPerShelf: 2,
currentPage: 0, currentPage: 0,
booksPerFetch: 20, booksPerFetch: 20,
initialized: false, initialized: false,
@@ -71,6 +73,7 @@ export default {
return this.page return this.page
}, },
hasFilter() { hasFilter() {
if (this.page === 'series' || this.page === 'collections') return false
return this.filterBy !== 'all' return this.filterBy !== 'all'
}, },
orderBy() { orderBy() {
@@ -82,14 +85,11 @@ export default {
filterBy() { filterBy() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy') return this.$store.getters['user/getUserSetting']('mobileFilterBy')
}, },
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
isCoverSquareAspectRatio() { isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE return this.bookCoverAspectRatio === 1
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.isCoverSquareAspectRatio ? 1 : 1.6 return this.$store.getters['getBookCoverAspectRatio']
}, },
bookWidth() { bookWidth() {
var coverSize = 100 var coverSize = 100
@@ -118,7 +118,7 @@ export default {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
}, },
shelfHeight() { shelfHeight() {
if (this.showBookshelfListView) return this.entityHeight if (this.showBookshelfListView) return this.entityHeight + 16
return this.entityHeight + 40 return this.entityHeight + 40
}, },
totalEntityCardWidth() { totalEntityCardWidth() {
@@ -299,7 +299,6 @@ export default {
this.bookshelfHeight = clientHeight this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth this.bookshelfWidth = clientWidth
this.entitiesPerShelf = this.showBookshelfListView ? 1 : Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth) this.entitiesPerShelf = this.showBookshelfListView ? 1 : Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2 this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2 this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
@@ -319,6 +318,15 @@ export default {
await this.loadPage(0) await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex) this.mountEntites(0, lastBookIndex)
// Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window['bookshelf-wrapper']) {
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
if (path === this.routeFullPath) {
// Exact path match with query so use scroll position
window['bookshelf-wrapper'].scrollTop = scrollTop
}
}
}, },
scroll(e) { scroll(e) {
if (!e || !e.target) return if (!e || !e.target) return
@@ -361,6 +369,8 @@ export default {
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) { if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl) window.history.replaceState({ path: newurl }, '', newurl)
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
return true return true
} }
@@ -455,12 +465,22 @@ export default {
this.$socket.$off('items_added', this.libraryItemsAdded) this.$socket.$off('items_added', this.libraryItemsAdded)
} }
}, },
updated() {
this.routeFullPath = window.location.pathname + (window.location.search || '')
},
mounted() { mounted() {
this.routeFullPath = window.location.pathname + (window.location.search || '')
this.init() this.init()
this.initListeners() this.initListeners()
}, },
beforeDestroy() { beforeDestroy() {
this.removeListeners() this.removeListeners()
// Set bookshelf scroll position for specific bookshelf page and query
if (window['bookshelf-wrapper']) {
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window['bookshelf-wrapper'].scrollTop || 0, path: this.routeFullPath, name: this.page })
}
} }
} }
</script> </script>
+1
View File
@@ -3,6 +3,7 @@
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }"> <div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<template v-for="(entity, index) in entities"> <template v-for="(entity, index) in entities">
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" /> <cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" /> <cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" /> <cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
</template> </template>
+85 -36
View File
@@ -34,8 +34,13 @@
</div> </div>
</div> </div>
<!-- Play/pause button for podcast episode -->
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70">
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
</div>
<!-- No progress shown for collapsed series in library --> <!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && !isPodcast" 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="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` }">
<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>
@@ -46,13 +51,18 @@
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span> <span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div> </div>
<!-- Volume number --> <!-- Series sequence -->
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div> </div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
</div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
<div v-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }"> <div v-else-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div> </div>
</div> </div>
@@ -196,6 +206,17 @@ export default {
seriesSequence() { seriesSequence() {
return this.series ? this.series.sequence : null return this.series ? this.series.sequence : null
}, },
recentEpisode() {
// Only added to item when getting currently listening podcasts
return this._libraryItem.recentEpisode
},
recentEpisodeNumber() {
if (!this.recentEpisode) return null
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return this.recentEpisode.index
},
collapsedSeries() { collapsedSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries return this._libraryItem.collapsedSeries
@@ -222,7 +243,14 @@ export default {
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null return null
}, },
episodeProgress() {
// Only used on home page currently listening podcast shelf
if (!this.recentEpisode) return null
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.recentEpisode.id)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() { userProgress() {
if (this.episodeProgress) return this.episodeProgress
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId) if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
@@ -233,19 +261,28 @@ export default {
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false
}, },
showError() { showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid return this.numMissingParts || this.isMissing || this.isInvalid
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
localLibraryItemId() {
if (this.isLocal) return this.libraryItemId
return this.localLibraryItem ? this.localLibraryItem.id : null
}, },
isStreaming() { isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId if (this.isPodcast) {
if (this.playerIsLocal) {
// Check is streaming local version of this episode
return false // episode cards not implemented for local yet
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
} else {
return false // not yet necessary for books
}
}, },
showReadButton() { streamIsPlaying() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook return this.$store.state.playerIsPlaying && this.isStreaming
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
}, },
isMissing() { isMissing() {
return this._libraryItem.isMissing return this._libraryItem.isMissing
@@ -253,24 +290,9 @@ export default {
isInvalid() { isInvalid() {
return this._libraryItem.isInvalid return this._libraryItem.isInvalid
}, },
hasMissingParts() { numMissingParts() {
return this._libraryItem.hasMissingParts if (this.isPodcast) return 0
}, return this.media.numMissingParts
hasInvalidParts() {
return this._libraryItem.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no media files'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
}, },
overlayWrapperClasslist() { overlayWrapperClasslist() {
var classes = [] var classes = []
@@ -331,7 +353,34 @@ export default {
this.isSelectionMode = val this.isSelectionMode = val
if (!val) this.selected = false if (!val) this.selected = false
}, },
setEntity(libraryItem) { setEntity(_libraryItem) {
var libraryItem = _libraryItem
// this code block is only necessary when showing a selected series with sequence #
// it will update the selected series so we get realtime updates for series sequence changes
if (this.series) {
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
libraryItem = {
..._libraryItem,
media: {
..._libraryItem.media,
metadata: {
..._libraryItem.media.metadata
}
}
}
var mediaMetadata = libraryItem.media.metadata
if (mediaMetadata.series) {
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
if (newSeries) {
// update selected series
libraryItem.media.metadata.series = newSeries
this.libraryItem = libraryItem
return
}
}
}
this.libraryItem = libraryItem this.libraryItem = libraryItem
}, },
setLocalLibraryItem(localLibraryItem) { setLocalLibraryItem(localLibraryItem) {
@@ -343,6 +392,10 @@ export default {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
this.selectBtnClick() this.selectBtnClick()
} else if (this.recentEpisode) {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) eventBus.$emit('pause-item')
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
} else { } else {
var router = this.$router || this.$nuxt.$router var router = this.$router || this.$nuxt.$router
if (router) { if (router) {
@@ -420,10 +473,6 @@ export default {
this.selected = !this.selected this.selected = !this.selected
this.$emit('select', this.libraryItem) this.$emit('select', this.libraryItem)
}, },
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
destroy() { destroy() {
// destroy the vue listeners, etc // destroy the vue listeners, etc
this.$destroy() this.$destroy()
+16
View File
@@ -20,6 +20,8 @@
</p> </p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
</div> </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="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` }">
@@ -99,9 +101,23 @@ export default {
mediaType() { mediaType() {
return this._libraryItem.mediaType return this._libraryItem.mediaType
}, },
duration() {
return this.media.duration || null
},
isPodcast() { isPodcast() {
return this.mediaType === 'podcast' return this.mediaType === 'podcast'
}, },
episodes() {
if (this.isPodcast) {
if (this.media.numEpisodes==1) {
return "1 episode"
} else {
return this.media.numEpisodes + ' episodes'
}
} else {
return null
}
},
placeholderUrl() { placeholderUrl() {
return '/book_placeholder.jpg' return '/book_placeholder.jpg'
}, },
+6 -3
View File
@@ -54,13 +54,16 @@ export default {
updatedAt() { updatedAt() {
return this._author.updatedAt return this._author.updatedAt
}, },
serverAddress() {
return this.$store.getters['user/getServerAddress']
},
imgSrc() { imgSrc() {
if (!this.imagePath) return null if (!this.imagePath || !this.serverAddress) return null
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production' && this.serverAddress.startsWith('http://192.168')) {
// Testing // Testing
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}` return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
} }
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}` return `${this.serverAddress}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
} }
}, },
methods: { methods: {
+2 -2
View File
@@ -5,7 +5,7 @@
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
</div> </div>
<img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg && !hasCover ? 'object-contain' : 'object-fill'" /> <img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="showCoverBg && hasCover ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
@@ -121,7 +121,7 @@ export default {
return this.media.coverPath || this.placeholderUrl return this.media.coverPath || this.placeholderUrl
}, },
hasCover() { hasCover() {
return !!this.media.coverPath || this.localCover || this.downloadCover return (!!this.media.coverPath && !this.isLocal) || this.localCover || this.downloadCover
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 128 : 96 var baseSize = this.squareAspectRatio ? 128 : 96
+60 -25
View File
@@ -45,6 +45,7 @@
</template> </template>
<script> <script>
import { Dialog } from '@capacitor/dialog'
export default { export default {
props: { props: {
value: Boolean, value: Boolean,
@@ -56,7 +57,7 @@ export default {
type: Number, type: Number,
default: 0 default: 0
}, },
audiobookId: String libraryItemId: String
}, },
data() { data() {
return { return {
@@ -82,11 +83,7 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
isConnected() {
return this.$store.state.socketConnected
},
canCreateBookmark() { canCreateBookmark() {
if (!this.isConnected) return false
return !this.bookmarks.find((bm) => bm.time === this.currentTime) return !this.bookmarks.find((bm) => bm.time === this.currentTime)
} }
}, },
@@ -96,40 +93,78 @@ export default {
this.newBookmarkTitle = bm.title this.newBookmarkTitle = bm.title
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
deleteBookmark(bm) { async deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId } const { value } = await Dialog.confirm({
this.$server.socket.emit('delete_bookmark', bookmark) title: 'Remove Bookmark',
message: `Are you sure you want to remove bookmark?`
})
if (!value) return
this.$axios
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
.then(() => {
this.$toast.success('Bookmark removed')
})
.catch((error) => {
this.$toast.error(`Failed to remove bookmark`)
console.error(error)
})
this.show = false
}, },
clickBookmark(bm) { clickBookmark(bm) {
this.$emit('select', bm) this.$emit('select', bm)
}, },
submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark }
this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark updated')
})
.catch((error) => {
this.$toast.error(`Failed to update bookmark`)
console.error(error)
})
this.show = false
},
submitCreateBookmark() {
if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
}
var bookmark = {
title: this.newBookmarkTitle,
time: Math.floor(this.currentTime)
}
this.$axios
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark added')
})
.catch((error) => {
this.$toast.error(`Failed to create bookmark`)
console.error(error)
})
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
this.show = false
},
createBookmark() { createBookmark() {
this.selectedBookmark = null this.selectedBookmark = null
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm') this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
submitBookmark() { submitBookmark() {
console.log(`[BookmarksModal] Submit Bookmark ${this.newBookmarkTitle}/${this.audiobookId}`)
if (this.selectedBookmark) { if (this.selectedBookmark) {
if (this.selectedBookmark.title !== this.newBookmarkTitle) { var updatePayload = {
var bookmark = { ...this.selectedBookmark } ...this.selectedBookmark,
bookmark.audiobookId = this.audiobookId title: this.newBookmarkTitle
bookmark.title = this.newBookmarkTitle
console.log(`[BookmarksModal] Update Bookmark ${JSON.stringify(bookmark)}`)
this.$server.socket.emit('update_bookmark', bookmark)
} }
this.submitUpdateBookmark(updatePayload)
} else { } else {
var bookmark = { this.submitCreateBookmark()
audiobookId: this.audiobookId,
title: this.newBookmarkTitle,
time: this.currentTime
}
console.log(`[BookmarksModal] Create Bookmark ${JSON.stringify(bookmark)}`)
this.$server.socket.emit('create_bookmark', bookmark)
} }
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
this.show = false
} }
}, },
mounted() {} mounted() {}
+3 -2
View File
@@ -10,7 +10,7 @@
<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" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3"> <div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div> </div>
@@ -30,7 +30,8 @@ export default {
items: { items: {
type: Array, type: Array,
default: () => [] default: () => []
} },
selected: String // optional
}, },
data() { data() {
return {} return {}
+1 -1
View File
@@ -166,7 +166,7 @@ export default {
return this.filterData.narrators || [] return this.filterData.narrators || []
}, },
progress() { progress() {
return ['Finished', 'In Progress', 'Not Started'] return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
+52
View File
@@ -0,0 +1,52 @@
<template>
<modals-modal v-model="show" :width="400" height="100%">
<template #outer>
<div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Details</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
<p class="mb-1">{{ mediaMetadata.title }}</p>
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_libraryItem() {
return this.libraryItem || {}
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
}
},
methods: {},
mounted() {}
}
</script>
+4
View File
@@ -47,6 +47,10 @@ export default {
text: 'Size', text: 'Size',
value: 'size' value: 'size'
}, },
{
text: 'Duration',
value: 'media.duration'
},
{ {
text: 'File Birthtime', text: 'File Birthtime',
value: 'birthtimeMs' value: 'birthtimeMs'
+5 -5
View File
@@ -1,11 +1,11 @@
<template> <template>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-4 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click"> <div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-3 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
<span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> <span class="material-icons text-xl" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
<p class="pl-2 pr-2 truncate">{{ bookmark.title }}</p> <p class="pl-2 pr-2 truncate text-sm">{{ bookmark.title }}</p>
</div> </div>
<div class="h-full flex items-center w-16 justify-end"> <div class="h-full flex items-center w-14 justify-end">
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span> <span class="font-mono text-xs text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
</div> </div>
<div class="h-full flex items-center justify-end transform w-16"> <div class="h-full flex items-center justify-end transform w-16">
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span> <span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
+19 -9
View File
@@ -27,9 +27,11 @@
<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" />
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span> <div v-if="!isIos && userCanDownload">
<span v-else-if="!localEpisode" class="material-icons px-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-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</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>
</div>
</div> </div>
</div> </div>
@@ -39,7 +41,7 @@
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { AbsDownloader } from '@/plugins/capacitor' import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
export default { export default {
props: { props: {
@@ -61,9 +63,15 @@ export default {
} }
}, },
computed: { computed: {
isIos() {
return this.$platform === 'ios'
},
mediaType() { mediaType() {
return 'podcast' return 'podcast'
}, },
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
audioFile() { audioFile() {
return this.episode.audioFile return this.episode.audioFile
}, },
@@ -127,8 +135,12 @@ export default {
} }
}, },
methods: { methods: {
selectFolder() { async selectFolder() {
this.$toast.error('Folder selector not implemented for podcasts yet') var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
return folderObj
}, },
downloadClick() { downloadClick() {
if (this.downloadItem) return if (this.downloadItem) return
@@ -204,9 +216,7 @@ export default {
var isFinished = !this.userIsFinished var isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var localMediaProgressId = `${localLibraryItemId}-${localEpisodeId}` var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished local media progress id', localMediaProgressId, isFinished)
var payload = await this.$db.updateLocalMediaProgressFinished({ localMediaProgressId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload)) console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) { if (!payload || payload.error) {
var errorMsg = payload ? payload.error : 'Unknown error' var errorMsg = payload ? payload.error : 'Unknown error'
+126 -4
View File
@@ -1,10 +1,28 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodes.length }})</p> <div class="flex items-center">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
<div class="flex-grow" />
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
<span class="material-icons text-xl text-gray-200">filter_alt</span>
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
</button>
<template v-for="episode in episodes"> <div class="flex items-center border border-white border-opacity-25 rounded px-2" @click="clickSort">
<p class="text-sm text-gray-200">{{ sortText }}</p>
<span class="material-icons ml-1 text-gray-200">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</div>
<template v-for="episode in episodesSorted">
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" /> <tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" />
</template> </template>
<!-- What in tarnation is going on here?
Without anything below the template it will not re-render -->
<p>&nbsp;</p>
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
</div> </div>
</template> </template>
@@ -24,9 +42,86 @@ export default {
isLocal: Boolean // If is local then episodes and libraryItemId are local, otherwise local is passed in localLibraryItemId and localEpisodes isLocal: Boolean // If is local then episodes and libraryItemId are local, otherwise local is passed in localLibraryItemId and localEpisodes
}, },
data() { data() {
return {} return {
episodesCopy: [],
showFiltersModal: false,
sortKey: 'publishedAt',
sortDesc: false,
filterKey: 'incomplete',
episodeSortItems: [
{
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Season',
value: 'season'
},
{
text: 'Episode',
value: 'episode'
}
],
filterItems: [
{
text: 'Show All',
value: 'all'
},
{
text: 'Incomplete',
value: 'incomplete'
},
{
text: 'In Progress',
value: 'inProgress'
},
{
text: 'Complete',
value: 'complete'
}
]
}
},
watch: {
episodes: {
immediate: true,
handler() {
this.init()
}
}
}, },
computed: { computed: {
episodesAreFiltered() {
return this.episodesFiltered.length !== this.episodesCopy.length
},
episodesFiltered() {
return this.episodesCopy.filter((ep) => {
var mediaProgress = this.getEpisodeProgress(ep)
if (this.filterKey === 'incomplete') {
return !mediaProgress || !mediaProgress.isFinished
} else if (this.filterKey === 'complete') {
return mediaProgress && mediaProgress.isFinished
} else if (this.filterKey === 'inProgress') {
return mediaProgress && !mediaProgress.isFinished
} else if (this.filterKey === 'all') {
console.log('Filter key is all')
return true
}
return true
})
},
episodesSorted() {
return this.episodesFiltered.sort((a, b) => {
if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
})
},
// Map of local episodes where server episode id is key // Map of local episodes where server episode id is key
localEpisodeMap() { localEpisodeMap() {
var epmap = {} var epmap = {}
@@ -36,9 +131,36 @@ export default {
} }
}) })
return epmap return epmap
},
sortText() {
if (!this.sortKey) return ''
var _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
if (!_sel) return ''
return _sel.text
}
},
methods: {
setFilter(filter) {
this.filterKey = filter
console.log('Set filter', this.filterKey)
this.showFiltersModal = false
},
showFilters() {
this.showFiltersModal = true
},
clickSort() {
this.sortDesc = !this.sortDesc
},
getEpisodeProgress(episode) {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
},
init() {
this.episodesCopy = this.episodes.map((ep) => {
return { ...ep }
})
} }
}, },
methods: {},
mounted() {} mounted() {}
} }
</script> </script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn"> <button class="icon-btn rounded-md flex items-center justify-center px-2 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative"> <div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)"> <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
+48
View File
@@ -0,0 +1,48 @@
<template>
<div>
<div class="border rounded-full border-gray-400 flex items-center cursor-pointer w-10 justify-start" :class="className" @click.stop="clickToggle">
<span class="rounded-full border w-5 h-5 border-gray-100 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
onColor: {
type: String,
default: 'success'
},
offColor: {
type: String,
default: 'primary'
},
disabled: Boolean
},
computed: {
toggleValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
className() {
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
}
},
methods: {
clickToggle() {
if (this.disabled) return
this.toggleValue = !this.toggleValue
}
}
}
</script>
@@ -97,6 +97,11 @@ export default {
this.$eventBus.$emit('new-local-library-item', data.localLibraryItem) this.$eventBus.$emit('new-local-library-item', data.localLibraryItem)
} }
if (data.localMediaProgress) {
console.log('onItemDownloadComplete updating local media progress', data.localMediaProgress.id)
this.$store.commit('globals/updateLocalMediaProgress', data.localMediaProgress)
}
this.$store.commit('globals/removeItemDownload', data.libraryItemId) this.$store.commit('globals/removeItemDownload', data.libraryItemId)
} }
}, },
+25 -5
View File
@@ -23,6 +23,10 @@
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */; }; 3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */; };
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */; }; 3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */; };
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */; }; 3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */; };
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B951282EE822008272D4 /* AbsDownloader.m */; };
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
@@ -30,6 +34,7 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -49,6 +54,10 @@
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsAudioPlayer.swift; sourceTree = "<group>"; }; 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsAudioPlayer.swift; sourceTree = "<group>"; };
3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsAudioPlayer.m; sourceTree = "<group>"; }; 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsAudioPlayer.m; sourceTree = "<group>"; };
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingInfo.swift; sourceTree = "<group>"; }; 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingInfo.swift; sourceTree = "<group>"; };
4D66B951282EE822008272D4 /* AbsDownloader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsDownloader.m; sourceTree = "<group>"; };
4D66B953282EE87C008272D4 /* AbsDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDownloader.swift; sourceTree = "<group>"; };
4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; };
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
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>"; };
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 /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -60,6 +69,7 @@
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = "<group>"; };
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 */
@@ -109,6 +119,10 @@
3AD4FCE628043E72006DB301 /* AbsDatabase.m */, 3AD4FCE628043E72006DB301 /* AbsDatabase.m */,
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */, 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */,
3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */, 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */,
4D66B951282EE822008272D4 /* AbsDownloader.m */,
4D66B953282EE87C008272D4 /* AbsDownloader.swift */,
4D66B955282EE951008272D4 /* AbsFileSystem.m */,
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */,
); );
path = plugins; path = plugins;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -118,6 +132,7 @@
children = ( children = (
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */, 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */, 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
3A90295E280968E700E1D427 /* PlaybackReport.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */,
); );
path = models; path = models;
@@ -300,15 +315,20 @@
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */, 3ABF580928059BAE005DFBE5 /* PlaybackSession.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 */,
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */, 3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */, 3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */, 3A200C1527D64D7E00CBF02E /* AudioPlayer.swift 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 */,
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 */,
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */, 3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */, 3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
3AB34055280832720039308B /* PlayerEvents.swift in Sources */, 3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -455,14 +475,14 @@
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 = 4; CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.41; MARKETING_VERSION = 0.9.47;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.development; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OBJC_BRIDGING_HEADER = "App/App-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "App/App-Bridging-Header.h";
@@ -479,12 +499,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.41; MARKETING_VERSION = 0.9.47;
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 = "";
+7 -1
View File
@@ -14,13 +14,19 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seek, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(seek, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seekForward, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(seekForward, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seekBackward, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(seekBackward, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getCurrentTime, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getCurrentTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(cancelSleepTimer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(decreaseSleepTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(increaseSleepTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getSleepTimerTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setSleepTimer, CAPPluginReturnPromise);
) )
+106 -39
View File
@@ -10,18 +10,26 @@ import Capacitor
@objc(AbsAudioPlayer) @objc(AbsAudioPlayer)
public class AbsAudioPlayer: CAPPlugin { public class AbsAudioPlayer: CAPPlugin {
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)
NotificationCenter.default.addObserver(self, selector: #selector(sendPlaybackClosedEvent), name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendPlaybackClosedEvent), name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.didBecomeActiveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.willEnterForegroundNotification, 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(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
} }
@objc func prepareLibraryItem(_ call: CAPPluginCall) { @objc func prepareLibraryItem(_ call: CAPPluginCall) {
let libraryItemId = call.getString("libraryItemId") let libraryItemId = call.getString("libraryItemId")
let episodeId = call.getString("episodeId") let episodeId = call.getString("episodeId")
let playWhenReady = call.getBool("playWhenReady", true) let playWhenReady = call.getBool("playWhenReady", true)
let playbackRate = call.getFloat("playbackRate", 1)
if libraryItemId == nil { if libraryItemId == nil {
NSLog("provide library item id") NSLog("provide library item id")
@@ -32,9 +40,12 @@ public class AbsAudioPlayer: CAPPlugin {
return call.resolve() return call.resolve()
} }
initialPlayWhenReady = playWhenReady
initialPlaybackRate = playbackRate
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId) { session in ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady) PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
do { do {
self.sendPlaybackSession(session: try session.asDictionary()) self.sendPlaybackSession(session: try session.asDictionary())
@@ -42,7 +53,6 @@ public class AbsAudioPlayer: CAPPlugin {
} catch(let exception) { } catch(let exception) {
NSLog("failed to convert session to json") NSLog("failed to convert session to json")
debugPrint(exception) debugPrint(exception)
call.resolve([:]) call.resolve([:])
} }
@@ -67,18 +77,19 @@ public class AbsAudioPlayer: CAPPlugin {
call.resolve() call.resolve()
} }
@objc func playPause(_ call: CAPPluginCall) {
PlayerHandler.playPause()
call.resolve([ "playing": !PlayerHandler.paused() ])
}
@objc func playPlayer(_ call: CAPPluginCall) { @objc func playPlayer(_ call: CAPPluginCall) {
PlayerHandler.play() PlayerHandler.paused = false
call.resolve() call.resolve()
} }
@objc func pausePlayer(_ call: CAPPluginCall) { @objc func pausePlayer(_ call: CAPPluginCall) {
PlayerHandler.pause() PlayerHandler.paused = true
call.resolve() call.resolve()
} }
// I have no clue why but after i moved this block of code from above "playPlayer" to here the app stopped crashing. Move it back up if you want to
@objc func playPause(_ call: CAPPluginCall) {
PlayerHandler.paused = !PlayerHandler.paused
call.resolve([ "playing": !PlayerHandler.paused ])
}
@objc func seek(_ call: CAPPluginCall) { @objc func seek(_ call: CAPPluginCall) {
PlayerHandler.seek(amount: call.getDouble("value", 0.0)) PlayerHandler.seek(amount: call.getDouble("value", 0.0))
@@ -94,13 +105,97 @@ 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()) self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
} }
@objc func sendPlaybackClosedEvent() { @objc func sendPlaybackClosedEvent() {
self.notifyListeners("onPlaybackClosed", data: [ "value": true ]) self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
} }
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000)
call.resolve()
}
@objc func increaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000)
call.resolve()
}
@objc func setSleepTimer(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
NSLog("chapter time: \(call.getBool("isChapterTime", false))")
if call.getBool("isChapterTime", false) {
let timeToPause = time / 1000 - Int(PlayerHandler.getCurrentTime() ?? 0)
if timeToPause < 0 { return call.resolve([ "success": false ]) }
NSLog("oof \(timeToPause)")
PlayerHandler.remainingSleepTime = timeToPause
return call.resolve([ "success": true ])
}
PlayerHandler.remainingSleepTime = time / 1000
call.resolve([ "success": true ])
}
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
PlayerHandler.remainingSleepTime = nil
call.resolve()
}
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
call.resolve([
"value": PlayerHandler.remainingSleepTime
])
}
@objc func sendSleepTimerEnded() {
self.notifyListeners("onSleepTimerEnded", data: [
"value": PlayerHandler.getCurrentTime()
])
}
@objc func sendSleepTimerSet() {
self.notifyListeners("onSleepTimerSet", data: [
"value": PlayerHandler.remainingSleepTime
])
}
@objc func onPlaybackFailed() {
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
let playbackSession = PlayerHandler.getPlaybackSession()
let libraryItemId = playbackSession?.libraryItemId ?? ""
let episodeId = playbackSession?.episodeId ?? nil
NSLog("TEST: Forcing Transcode")
// If direct playing then fallback to transcode
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
do {
self.sendPlaybackSession(session: try session.asDictionary())
} catch(let exception) {
NSLog("failed to convert session to json")
debugPrint(exception)
}
self.sendMetadata()
}
} else {
self.notifyListeners("onPlaybackFailed", data: [
"value": "Playback Error"
])
}
}
@objc func sendPrepareMetadataEvent(itemId: String, playWhenReady: Bool) { @objc func sendPrepareMetadataEvent(itemId: String, playWhenReady: Bool) {
self.notifyListeners("onPrepareMedia", data: [ self.notifyListeners("onPrepareMedia", data: [
"audiobookId": itemId, "audiobookId": itemId,
@@ -110,32 +205,4 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func sendPlaybackSession(session: [String: Any]) { @objc func sendPlaybackSession(session: [String: Any]) {
self.notifyListeners("onPlaybackSession", data: session) self.notifyListeners("onPlaybackSession", data: session)
} }
/*
IMPLEMENTED:
cancelSleepTimer
decreaseSleepTime
increaseSleepTime
getSleepTimerTime
setSleepTimer
* closePlayback
* setPlaybackSpeed
* seekBackward
* seekForward
* seek
* playPause
* playPlayer
* pausePlayer
* getCurrentTime
* onPlaybackSession
* onPrepareMedia
* onPlaybackClosed
* onPlayingUpdate
* onMetadata
onSleepTimerEnded
onSleepTimerSet
*/
} }
+4 -5
View File
@@ -53,7 +53,7 @@ public class AbsDatabase: CAPPlugin {
} }
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) { @objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
let id = call.getString("serverConnectionConfigId", "") let id = call.getString("serverConnectionConfigId", "")
Database.deleteServerConnectionConfig(id: id) Database.shared.deleteServerConnectionConfig(id: id)
call.resolve() call.resolve()
} }
@@ -63,13 +63,12 @@ public class AbsDatabase: CAPPlugin {
} }
@objc func getDeviceData(_ call: CAPPluginCall) { @objc func getDeviceData(_ call: CAPPluginCall) {
let configs = Database.getServerConnectionConfigs() let configs = Database.shared.getServerConnectionConfigs()
let index = Database.getLastActiveConfigIndex() let index = Database.shared.getLastActiveConfigIndex()
call.resolve([ call.resolve([
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) }, "serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id, "lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
// Luckily this isn't implemented yet
// "currentLocalPlaybackSession": nil, // "currentLocalPlaybackSession": nil,
]) ])
} }
+13
View File
@@ -0,0 +1,13 @@
//
// AbsDownloader.m
// App
//
// Created by advplyr on 5/13/22.
//
#import <Foundation/Foundation.h>
#import <Capacitor/Capacitor.h>
CAP_PLUGIN(AbsDownloader, "AbsDownloader",
CAP_PLUGIN_METHOD(downloadLibraryItem, CAPPluginReturnPromise);
)
+71
View File
@@ -0,0 +1,71 @@
//
// AbsDownloader.swift
// App
//
// Created by advplyr on 5/13/22.
//
import Foundation
import Capacitor
@objc(AbsDownloader)
public class AbsDownloader: CAPPlugin {
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
let libraryItemId = call.getString("libraryItemId")
let episodeId = call.getString("episodeId")
NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")")
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in
if (libraryItem == nil) {
NSLog("Library item not found")
call.resolve()
} else {
NSLog("Got library item \(libraryItem!)")
// TODO: break out in seperate functions
libraryItem!.media.tracks?.forEach { track in
NSLog("TRACK \(track.contentUrl!)")
// filename needs to be encoded otherwise would just use contentUrl
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItemId!)")
NSLog("ITEM DIR \(itemDirectory)")
// Create library item directory
do {
try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false)
} catch {
NSLog("Failed to CREATE LI DIRECTORY \(error)")
}
// Output filename
let trackFilename = itemDirectory.appendingPathComponent("\(filename)")
let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in
guard let fileURL = urlOrNil else { return }
do {
NSLog("Download TMP file URL \(fileURL)")
let imageData = try Data(contentsOf:fileURL)
try imageData.write(to: trackFilename)
NSLog("Download written to \(trackFilename)")
} catch {
NSLog("FILE ERROR: \(error)")
}
}
downloadTask.resume()
}
call.resolve()
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//
// AbsFileSystem.m
// App
//
// Created by advplyr on 5/13/22.
//
#import <Foundation/Foundation.h>
#import <Capacitor/Capacitor.h>
CAP_PLUGIN(AbsFileSystem, "AbsFileSystem",
CAP_PLUGIN_METHOD(selectFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(checkFolderPermission, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(scanFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(removeFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(removeLocalLibraryItem, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(scanLocalLibraryItem, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(deleteItem, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(deleteTrackFromItem, CAPPluginReturnPromise);
)
+91
View File
@@ -0,0 +1,91 @@
//
// AbsFileSystem.swift
// App
//
// Created by advplyr on 5/13/22.
//
import Foundation
import Capacitor
@objc(AbsFileSystem)
public class AbsFileSystem: CAPPlugin {
@objc func selectFolder(_ call: CAPPluginCall) {
let mediaType = call.getString("mediaType")
// TODO: Implement
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
call.resolve()
}
@objc func checkFolderPermission(_ call: CAPPluginCall) {
let folderUrl = call.getString("folderUrl")
// TODO: Is this even necessary on iOS?
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
call.resolve([
"value": true
])
}
@objc func scanFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId")
let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve()
}
@objc func removeFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId")
// TODO: Implement
NSLog("removeFolder \(folderId ?? "UNSET")")
call.resolve()
}
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
// TODO: Implement
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
call.resolve()
}
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve()
}
@objc func deleteItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let contentUrl = call.getString("contentUrl")
// TODO: Implement
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
call.resolve()
}
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let trackLocalFileId = call.getString("trackLocalFileId")
let contentUrl = call.getString("contentUrl")
// TODO: Implement
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
call.resolve()
}
}
-1
View File
@@ -15,7 +15,6 @@ def capacitor_pods
pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network' pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network'
pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar' pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar'
pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage' pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage'
pod 'RobingenzCapacitorAppUpdate', :path => '..\..\node_modules\@robingenz\capacitor-app-update'
end end
target 'App' do target 'App' do
+141
View File
@@ -0,0 +1,141 @@
//
// 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?
}
+1 -26
View File
@@ -25,33 +25,8 @@ struct PlaybackSession: Decodable, Encodable {
var timeListening: Double var timeListening: Double
var audioTracks: [AudioTrack] var audioTracks: [AudioTrack]
var currentTime: Double var currentTime: Double
// var libraryItem: LibraryItem? var libraryItem: LibraryItem
// var localLibraryItem: LocalLibraryItem? // var localLibraryItem: LocalLibraryItem?
var serverConnectionConfigId: String? var serverConnectionConfigId: String?
var serverAddress: String? var serverAddress: String?
} }
struct Chapter: Decodable, Encodable {
var id: Int
var start: Double
var end: Double
var title: String?
}
struct AudioTrack: Decodable, Encodable {
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: Decodable, Encodable {
var filename: String
var ext: String
var path: String
var relPath: String
}
+171 -60
View File
@@ -10,6 +10,13 @@ import AVFoundation
import UIKit import UIKit
import MediaPlayer import MediaPlayer
enum PlayMethod:Int {
case directplay = 0
case directstream = 1
case transcode = 2
case local = 3
}
class AudioPlayer: NSObject { class AudioPlayer: NSObject {
// enums and @objc are not compatible // enums and @objc are not compatible
@objc dynamic var status: Int @objc dynamic var status: Int
@@ -22,27 +29,27 @@ class AudioPlayer: NSObject {
private var playerItemContext = 0 private var playerItemContext = 0
private var playWhenReady: Bool private var playWhenReady: Bool
private var initialPlaybackRate: Float
private var audioPlayer: AVPlayer private var audioPlayer: AVQueuePlayer
private var playbackSession: PlaybackSession private var playbackSession: PlaybackSession
private var activeAudioTrack: AudioTrack
private var queueObserver:NSKeyValueObservation?
private var queueItemStatusObserver:NSKeyValueObservation?
private var currentTrackIndex = 0
private var allPlayerItems:[AVPlayerItem] = []
// MARK: - Constructor // MARK: - Constructor
init(playbackSession: PlaybackSession, playWhenReady: Bool = false) { init(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) {
self.playWhenReady = playWhenReady self.playWhenReady = playWhenReady
self.audioPlayer = AVPlayer() self.initialPlaybackRate = playbackRate
self.audioPlayer = AVQueuePlayer()
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
self.playbackSession = playbackSession self.playbackSession = playbackSession
self.status = -1 self.status = -1
self.rate = 0.0 self.rate = 0.0
self.tmpRate = playbackRate
if playbackSession.audioTracks.count != 1 || playbackSession.audioTracks[0].mimeType != "application/vnd.apple.mpegurl" {
NSLog("The player only support HLS streams right now")
self.activeAudioTrack = AudioTrack(index: 0, startOffset: -1, duration: -1, title: "", contentUrl: "", mimeType: "")
super.init()
return
}
self.activeAudioTrack = playbackSession.audioTracks[0]
super.init() super.init()
@@ -53,17 +60,35 @@ class AudioPlayer: NSObject {
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
let playerItem = AVPlayerItem(asset: createAsset()) for track in playbackSession.audioTracks {
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: .new, context: &playerItemContext) let playerItem = AVPlayerItem(asset: createAsset(itemId: playbackSession.libraryItemId!, track: track))
self.allPlayerItems.append(playerItem)
}
self.audioPlayer.replaceCurrentItem(with: playerItem) self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
NSLog("TEST: Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
NSLog("TEST: Setting player items \(playerItems.count)")
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
setupQueueObserver()
setupQueueItemStatusObserver()
NSLog("Audioplayer ready") NSLog("Audioplayer ready")
} }
deinit { deinit {
self.queueObserver?.invalidate()
self.queueItemStatusObserver?.invalidate()
destroy() destroy()
} }
public func destroy() { public func destroy() {
// Pause is not synchronous causing this error on below lines:
// AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session
// It is related to L79 `AVAudioSession.sharedInstance().setActive(false)`
pause() pause()
audioPlayer.replaceCurrentItem(with: nil) audioPlayer.replaceCurrentItem(with: nil)
@@ -74,12 +99,60 @@ class AudioPlayer: NSObject {
print(error) print(error)
} }
DispatchQueue.main.sync { DispatchQueue.runOnMainQueue {
UIApplication.shared.endReceivingRemoteControlEvents() UIApplication.shared.endReceivingRemoteControlEvents()
} }
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
} }
func getItemIndexForTime(time:Double) -> Int {
for index in 0..<self.allPlayerItems.count {
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
let duration = playbackSession.audioTracks[index].duration
let trackEnd = startOffset + duration
if (time < trackEnd.rounded(.down)) {
return index
}
}
return 0
}
func setupQueueObserver() {
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in
let prevTrackIndex = self.currentTrackIndex
self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) {
NSLog("TEST: New Current track index \(self.currentTrackIndex)")
}
}
}
}
func setupQueueItemStatusObserver() {
self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
if (playerItem.status == .readyToPlay) {
NSLog("TEST: queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
self.updateNowPlaying()
let firstReady = self.status < 0
self.status = 0
if self.playWhenReady {
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
self.playWhenReady = false
self.play()
} else if (firstReady) { // Only seek on first readyToPlay
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
}
} else if (playerItem.status == .failed) {
NSLog("TEST: queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
})
}
// MARK: - Methods // MARK: - Methods
public func play(allowSeekBack: Bool = false) { public func play(allowSeekBack: Bool = false) {
if allowSeekBack { if allowSeekBack {
@@ -103,7 +176,7 @@ class AudioPlayer: NSObject {
} }
if time != nil { if time != nil {
seek(getCurrentTime() - Double(time!)) seek(getCurrentTime() - Double(time!), from: "play")
} }
} }
lastPlayTime = Date.timeIntervalSinceReferenceDate lastPlayTime = Date.timeIntervalSinceReferenceDate
@@ -115,6 +188,7 @@ class AudioPlayer: NSObject {
updateNowPlaying() updateNowPlaying()
} }
public func pause() { public func pause() {
self.audioPlayer.pause() self.audioPlayer.pause()
self.status = 0 self.status = 0
@@ -123,24 +197,60 @@ class AudioPlayer: NSObject {
updateNowPlaying() updateNowPlaying()
lastPlayTime = Date.timeIntervalSinceReferenceDate lastPlayTime = Date.timeIntervalSinceReferenceDate
} }
public func seek(_ to: Double) {
let continuePlaing = rate > 0.0 public func seek(_ to: Double, from: String) {
let continuePlaying = rate > 0.0
pause() pause()
self.audioPlayer.seek(to: CMTime(seconds: to, preferredTimescale: 1000)) { completed in
if !completed { NSLog("TEST: Seek to \(to) from \(from)")
NSLog("WARNING: seeking not completed (to \(to)")
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration
NSLog("TEST: Seek current track END = \(trackEnd)")
let indexOfSeek = getItemIndexForTime(time: to)
NSLog("TEST: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
// Reconstruct queue if seeking to a different track
if (self.currentTrackIndex != indexOfSeek) {
self.currentTrackIndex = indexOfSeek
self.playbackSession.currentTime = to
self.playWhenReady = continuePlaying // Only playWhenReady if already playing
self.status = -1
let playerItems = self.allPlayerItems[indexOfSeek..<self.allPlayerItems.count]
self.audioPlayer.removeAllItems()
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
} }
if continuePlaing { setupQueueItemStatusObserver()
self.play() } else {
NSLog("TEST: Seeking in current item \(to)")
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = to - currentTrackStartOffset
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in
if !completed {
NSLog("WARNING: seeking not completed (to \(seekTime)")
}
if continuePlaying {
self.play()
}
self.updateNowPlaying()
} }
self.updateNowPlaying()
} }
} }
public func setPlaybackRate(_ rate: Float, observed: Bool = false) { public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
if self.audioPlayer.rate != rate { if self.audioPlayer.rate != rate {
NSLog("TEST: setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
self.audioPlayer.rate = rate self.audioPlayer.rate = rate
} }
if rate > 0.0 && !(observed && rate == 1) { if rate > 0.0 && !(observed && rate == 1) {
@@ -148,25 +258,42 @@ class AudioPlayer: NSObject {
} }
self.rate = rate self.rate = rate
self.updateNowPlaying() self.updateNowPlaying()
} }
public func getCurrentTime() -> Double { public func getCurrentTime() -> Double {
self.audioPlayer.currentTime().seconds let currentTrackTime = self.audioPlayer.currentTime().seconds
let audioTrack = playbackSession.audioTracks[currentTrackIndex]
let startOffset = audioTrack.startOffset ?? 0.0
return startOffset + currentTrackTime
}
public func getPlayMethod() -> Int {
return self.playbackSession.playMethod
}
public func getPlaybackSession() -> PlaybackSession {
return self.playbackSession
} }
public func getDuration() -> Double { public func getDuration() -> Double {
self.audioPlayer.currentItem?.duration.seconds ?? 0 return playbackSession.duration
} }
// MARK: - Private // MARK: - Private
private func createAsset() -> AVAsset { private func createAsset(itemId:String, track:AudioTrack) -> AVAsset {
let headers: [String: String] = [ if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
"Authorization": "Bearer \(Store.serverConfig!.token)" // The only reason this is separate is because the filename needs to be encoded
] let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(activeAudioTrack.contentUrl)")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
} else { // HLS Transcode
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
}
} }
private func initAudioSession() { private func initAudioSession() {
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay]) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay])
@@ -179,9 +306,9 @@ class AudioPlayer: NSObject {
// MARK: - Now playing // MARK: - Now playing
private func setupRemoteTransportControls() { private func setupRemoteTransportControls() {
// DispatchQueue.main.sync { DispatchQueue.runOnMainQueue {
UIApplication.shared.beginReceivingRemoteControlEvents() UIApplication.shared.beginReceivingRemoteControlEvents()
// } }
let commandCenter = MPRemoteCommandCenter.shared() let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true commandCenter.playCommand.isEnabled = true
@@ -202,7 +329,7 @@ class AudioPlayer: NSObject {
return .noSuchContent return .noSuchContent
} }
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue) seek(getCurrentTime() + command.preferredIntervals[0].doubleValue, from: "remote")
return .success return .success
} }
commandCenter.skipBackwardCommand.isEnabled = true commandCenter.skipBackwardCommand.isEnabled = true
@@ -212,7 +339,7 @@ class AudioPlayer: NSObject {
return .noSuchContent return .noSuchContent
} }
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue) seek(getCurrentTime() - command.preferredIntervals[0].doubleValue, from: "remote")
return .success return .success
} }
@@ -222,7 +349,7 @@ class AudioPlayer: NSObject {
return .noSuchContent return .noSuchContent
} }
self.seek(event.positionTime) self.seek(event.positionTime, from: "remote")
return .success return .success
} }
@@ -239,33 +366,17 @@ class AudioPlayer: NSObject {
} }
private func updateNowPlaying() { private func updateNowPlaying() {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NowPlayingInfo.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate) NowPlayingInfo.shared.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate)
} }
// MARK: - Observer // MARK: - Observer
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &playerItemContext { if context == &playerContext {
if keyPath == #keyPath(AVPlayer.status) {
guard let playerStatus = AVPlayerItem.Status(rawValue: (change?[.newKey] as? Int ?? -1)) else { return }
if playerStatus == .readyToPlay {
self.updateNowPlaying()
let firstReady = self.status < 0
self.status = 0
if self.playWhenReady {
seek(playbackSession.currentTime)
self.playWhenReady = false
self.play()
} else if (firstReady) { // Only seek on first readyToPlay
seek(playbackSession.currentTime)
}
}
}
} else if context == &playerContext {
if keyPath == #keyPath(AVPlayer.rate) { if keyPath == #keyPath(AVPlayer.rate) {
NSLog("TEST: playerContext observer player rate")
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true) self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
} else if keyPath == #keyPath(AVPlayer.currentItem) { } else if keyPath == #keyPath(AVPlayer.currentItem) {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NSLog("WARNING: Item ended") NSLog("WARNING: Item ended")
} }
} else { } else {
+75 -38
View File
@@ -12,24 +12,61 @@ class PlayerHandler {
private static var session: PlaybackSession? private static var session: PlaybackSession?
private static var timer: Timer? private static var timer: Timer?
private static var listeningTimePassedSinceLastSync = 0.0 private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? {
get {
return _remainingSleepTime
}
set(time) {
if time != nil && time! < 0 {
_remainingSleepTime = nil
} else {
_remainingSleepTime = time
}
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool) { if _remainingSleepTime == nil {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: _remainingSleepTime)
} else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: _remainingSleepTime)
}
}
}
private static var listeningTimePassedSinceLastSync: Double = 0.0
private static var lastSyncReport: PlaybackReport?
public static var paused: Bool {
get {
guard let player = player else {
return true
}
return player.rate == 0.0
}
set(paused) {
if paused {
self.player?.pause()
} else {
self.player?.play()
}
}
}
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
if player != nil { if player != nil {
player?.destroy() player?.destroy()
player = nil player = nil
} }
NowPlayingInfo.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil)) NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
self.session = session self.session = session
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady) player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
// DispatchQueue.main.sync { DispatchQueue.runOnMainQueue {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick() self.tick()
}
} }
// }
} }
public static func stopPlayback() { public static func stopPlayback() {
player?.destroy() player?.destroy()
@@ -38,7 +75,7 @@ class PlayerHandler {
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
NowPlayingInfo.reset() NowPlayingInfo.shared.reset()
} }
public static func getCurrentTime() -> Double? { public static func getCurrentTime() -> Double? {
@@ -47,45 +84,32 @@ class PlayerHandler {
public static func setPlaybackSpeed(speed: Float) { public static func setPlaybackSpeed(speed: Float) {
self.player?.setPlaybackRate(speed) self.player?.setPlaybackRate(speed)
} }
public static func getPlayMethod() -> Int? {
public static func play() { self.player?.getPlayMethod()
self.player?.play()
} }
public static func pause() { public static func getPlaybackSession() -> PlaybackSession? {
self.player?.play() self.player?.getPlaybackSession()
}
public static func playPause() {
if paused() {
self.player?.play()
} else {
self.player?.pause()
}
} }
public static func seekForward(amount: Double) { public static func seekForward(amount: Double) {
if player == nil { guard let player = player else {
return return
} }
let destinationTime = player!.getCurrentTime() + amount let destinationTime = player.getCurrentTime() + amount
player!.seek(destinationTime) player.seek(destinationTime, from: "handler")
} }
public static func seekBackward(amount: Double) { public static func seekBackward(amount: Double) {
if player == nil { guard let player = player else {
return return
} }
let destinationTime = player!.getCurrentTime() - amount let destinationTime = player.getCurrentTime() - amount
player!.seek(destinationTime) player.seek(destinationTime, from: "handler")
} }
public static func seek(amount: Double) { public static func seek(amount: Double) {
player?.seek(amount) player?.seek(amount, from: "handler")
} }
public static func paused() -> Bool {
player?.rate == 0.0
}
public static func getMetdata() -> [String: Any] { public static func getMetdata() -> [String: Any] {
DispatchQueue.main.async { DispatchQueue.main.async {
syncProgress() syncProgress()
@@ -94,29 +118,42 @@ class PlayerHandler {
return [ return [
"duration": player?.getDuration() ?? 0, "duration": player?.getDuration() ?? 0,
"currentTime": player?.getCurrentTime() ?? 0, "currentTime": player?.getCurrentTime() ?? 0,
"playerState": !paused(), "playerState": !paused,
"currentRate": player?.rate ?? 0, "currentRate": player?.rate ?? 0,
] ]
} }
private static func tick() { private static func tick() {
if !paused() { if !paused {
listeningTimePassedSinceLastSync += 1 listeningTimePassedSinceLastSync += 1
} }
if listeningTimePassedSinceLastSync > 3 { if listeningTimePassedSinceLastSync > 3 {
syncProgress() syncProgress()
} }
if remainingSleepTime != nil {
if remainingSleepTime! == 0 {
paused = true
}
remainingSleepTime! -= 1
}
} }
public static func syncProgress() { public static func syncProgress() {
if player == nil || session == nil { if session == nil { return }
guard let player = player else { return }
let playerCurrentTime = player.getCurrentTime()
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
// No need to syncProgress
return return
} }
let report = PlaybackReport(currentTime: player!.getCurrentTime(), duration: player!.getDuration(), timeListened: listeningTimePassedSinceLastSync) let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = player!.getCurrentTime() session!.currentTime = playerCurrentTime
listeningTimePassedSinceLastSync = 0 listeningTimePassedSinceLastSync = 0
lastSyncReport = report
// TODO: check if online // TODO: check if online
NSLog("sending playback report") NSLog("sending playback report")
+68 -4
View File
@@ -9,12 +9,25 @@ import Foundation
import Alamofire import Alamofire
class ApiClient { class ApiClient {
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: String], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) { public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
if let data = data {
completion(UIImage(data:data))
}
}).resume()
}
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: Any], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
return
}
let headers: HTTPHeaders = [ let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)" "Authorization": "Bearer \(Store.serverConfig!.token)"
] ]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers).responseDecodable(of: decodable) { response in
switch response.result { switch response.result {
case .success(let obj): case .success(let obj):
callback?(obj) callback?(obj)
@@ -25,6 +38,12 @@ class ApiClient {
} }
} }
public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) { public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [ let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)" "Authorization": "Bearer \(Store.serverConfig!.token)"
] ]
@@ -41,16 +60,51 @@ class ApiClient {
} }
} }
} }
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
callback?(nil)
return
}
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: PlaybackSession) -> Void) { let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .get, encoding: JSONEncoding.default, headers: headers).responseDecodable(of: decodable) { response in
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
print(error)
}
}
}
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
var endpoint = "api/items/\(libraryItemId)/play" var endpoint = "api/items/\(libraryItemId)/play"
if episodeId != nil { if episodeId != nil {
endpoint += "/\(episodeId!)" endpoint += "/\(episodeId!)"
} }
var systemInfo = utsname()
uname(&systemInfo)
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
ptr in String.init(validatingUTF8: ptr)
}
}
ApiClient.postResource(endpoint: endpoint, parameters: [ ApiClient.postResource(endpoint: endpoint, parameters: [
"forceTranscode": "true", // TODO: direct play "forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer", "mediaPlayer": "AVPlayer",
"deviceInfo": [
"manufacturer": "Apple",
"model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
]
], decodable: PlaybackSession.self) { obj in ], decodable: PlaybackSession.self) { obj in
var session = obj var session = obj
@@ -63,4 +117,14 @@ class ApiClient {
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) { public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) {
try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil) try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil)
} }
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
if episodeId != nil {
endpoint += "&episodeId=\(episodeId!)"
}
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in
callback(obj)
}
}
} }
+22 -13
View File
@@ -10,16 +10,25 @@ import RealmSwift
class Database { class Database {
// All DB releated actions must be executed on "realm-queue" // All DB releated actions must be executed on "realm-queue"
public static let realmQueue = DispatchQueue(label: "realm-queue") public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue")
private static var instance: Realm = try! Realm(queue: realmQueue) public static var shared = {
realmQueue.sync {
return Database()
}
}()
public static func setServerConnectionConfig(config: ServerConnectionConfig) { private var instance: Realm
private init() {
self.instance = try! Realm(queue: Database.realmQueue)
}
public func setServerConnectionConfig(config: ServerConnectionConfig) {
var refrence: ThreadSafeReference<ServerConnectionConfig>? var refrence: ThreadSafeReference<ServerConnectionConfig>?
if config.realm != nil { if config.realm != nil {
refrence = ThreadSafeReference(to: config) refrence = ThreadSafeReference(to: config)
} }
realmQueue.sync { Database.realmQueue.sync {
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
if config.index == 0 { if config.index == 0 {
@@ -55,8 +64,8 @@ class Database {
setLastActiveConfigIndex(index: config.index) setLastActiveConfigIndex(index: config.index)
} }
} }
public static func deleteServerConnectionConfig(id: String) { public func deleteServerConnectionConfig(id: String) {
realmQueue.sync { Database.realmQueue.sync {
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id) let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
do { do {
@@ -71,10 +80,10 @@ class Database {
} }
} }
} }
public static func getServerConnectionConfigs() -> [ServerConnectionConfig] { public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = [] var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
realmQueue.sync { Database.realmQueue.sync {
let configs = instance.objects(ServerConnectionConfig.self) let configs = instance.objects(ServerConnectionConfig.self)
refrences = configs.map { config in refrences = configs.map { config in
return ThreadSafeReference(to: config) return ThreadSafeReference(to: config)
@@ -94,12 +103,12 @@ class Database {
} }
} }
public static func setLastActiveConfigIndexToNil() { public func setLastActiveConfigIndexToNil() {
realmQueue.sync { Database.realmQueue.sync {
setLastActiveConfigIndex(index: nil) setLastActiveConfigIndex(index: nil)
} }
} }
public static func setLastActiveConfigIndex(index: Int?) { public func setLastActiveConfigIndex(index: Int?) {
let existing = instance.objects(ServerConnectionConfigActiveIndex.self) let existing = instance.objects(ServerConnectionConfigActiveIndex.self)
let obj = ServerConnectionConfigActiveIndex() let obj = ServerConnectionConfigActiveIndex()
obj.index = index obj.index = index
@@ -114,8 +123,8 @@ class Database {
debugPrint(exception) debugPrint(exception)
} }
} }
public static func getLastActiveConfigIndex() -> Int? { public func getLastActiveConfigIndex() -> Int? {
return realmQueue.sync { return Database.realmQueue.sync {
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
} }
} }
+11
View File
@@ -18,3 +18,14 @@ extension Encodable {
return dictionary return dictionary
} }
} }
extension DispatchQueue {
static func runOnMainQueue(callback: @escaping (() -> Void)) {
if Thread.isMainThread {
callback()
} else {
DispatchQueue.main.sync {
callback()
}
}
}
}
+14 -22
View File
@@ -8,14 +8,6 @@
import Foundation import Foundation
import MediaPlayer import MediaPlayer
func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
if let data = data {
completion(UIImage(data:data))
}
}).resume()
}
struct NowPlayingMetadata { struct NowPlayingMetadata {
var id: String var id: String
var itemId: String var itemId: String
@@ -26,22 +18,22 @@ struct NowPlayingMetadata {
} }
class NowPlayingInfo { class NowPlayingInfo {
private static var nowPlayingInfo: [String: Any] = [:] static var shared = {
return NowPlayingInfo()
}()
public static func setSessionMetadata(metadata: NowPlayingMetadata) { private var nowPlayingInfo: [String: Any]
private init() {
self.nowPlayingInfo = [:]
}
public func setSessionMetadata(metadata: NowPlayingMetadata) {
setMetadata(artwork: nil, metadata: metadata) setMetadata(artwork: nil, metadata: metadata)
/*
if !shouldFetchCover(id: metadata.id) || metadata.artworkUrl == nil {
return
}
*/
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else { guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
return return
} }
ApiClient.getData(from: url) { [self] image in
getData(from: url) { [self] image in
guard let downloadedImage = image else { guard let downloadedImage = image else {
return return
} }
@@ -52,7 +44,7 @@ class NowPlayingInfo {
self.setMetadata(artwork: artwork, metadata: metadata) self.setMetadata(artwork: artwork, metadata: metadata)
} }
} }
public static func update(duration: Double, currentTime: Double, rate: Float) { public func update(duration: Double, currentTime: Double, rate: Float) {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
@@ -60,12 +52,12 @@ class NowPlayingInfo {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
} }
public static func reset() { public func reset() {
nowPlayingInfo = [:] nowPlayingInfo = [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
} }
private static func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) { private func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) {
if metadata == nil { if metadata == nil {
return return
} }
@@ -84,7 +76,7 @@ class NowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown" nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
} }
private static func shouldFetchCover(id: String) -> Bool { private func shouldFetchCover(id: String) -> Bool {
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
} }
} }
+3
View File
@@ -10,4 +10,7 @@ import Foundation
enum PlayerEvents: String { enum PlayerEvents: String {
case update = "com.audiobookshelf.app.player.update" case update = "com.audiobookshelf.app.player.update"
case closed = "com.audiobookshelf.app.player.closed" case closed = "com.audiobookshelf.app.player.closed"
case sleepSet = "com.audiobookshelf.app.player.sleep.set"
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
case failed = "com.audiobookshelf.app.player.failed"
} }
+2 -2
View File
@@ -16,9 +16,9 @@ class Store {
} }
set(updated) { set(updated) {
if updated != nil { if updated != nil {
Database.setServerConnectionConfig(config: updated!) Database.shared.setServerConnectionConfig(config: updated!)
} else { } else {
Database.setLastActiveConfigIndexToNil() Database.shared.setLastActiveConfigIndexToNil()
} }
Database.realmQueue.sync { Database.realmQueue.sync {
+55 -35
View File
@@ -12,8 +12,6 @@
</template> </template>
<script> <script>
import { AppUpdate } from '@robingenz/capacitor-app-update'
export default { export default {
data() { data() {
return { return {
@@ -79,36 +77,6 @@ export default {
this.$refs.streamContainer.streamOpen(stream) this.$refs.streamContainer.streamOpen(stream)
} }
}, },
async clickUpdateToast() {
var immediateUpdateAllowed = this.$store.state.appUpdateInfo.immediateUpdateAllowed
if (immediateUpdateAllowed) {
await AppUpdate.performImmediateUpdate()
} else {
await AppUpdate.openAppStore()
}
},
async checkForUpdate() {
if (this.$platform == 'web') return
console.log('Checking for app update')
const result = await AppUpdate.getAppUpdateInfo()
if (!result) {
console.error('Invalid version check')
return
}
console.log('App Update Info', JSON.stringify(result))
this.$store.commit('setAppUpdateInfo', result)
if (result.updateAvailability === 2) {
setTimeout(() => {
this.$toast.info(`Update is available! Click to update.`, {
draggable: false,
hideProgressBar: false,
timeout: 20000,
closeButton: true,
onClick: this.clickUpdateToast
})
}, 5000)
}
},
async loadSavedSettings() { async loadSavedSettings() {
var userSavedServerSettings = await this.$localStore.getServerSettings() var userSavedServerSettings = await this.$localStore.getServerSettings()
if (userSavedServerSettings) { if (userSavedServerSettings) {
@@ -192,7 +160,9 @@ export default {
socketConnectionFailed(err) { socketConnectionFailed(err) {
this.$toast.error('Socket connection error: ' + err.message) this.$toast.error('Socket connection error: ' + err.message)
}, },
socketInit(data) {}, socketInit(data) {
console.log('Socket init', data)
},
async initLibraries() { async initLibraries() {
if (this.inittingLibraries) { if (this.inittingLibraries) {
return return
@@ -227,16 +197,66 @@ export default {
console.log('[default] syncLocalMediaProgress No local media progress to sync') console.log('[default] syncLocalMediaProgress No local media progress to sync')
} }
}, },
userUpdated(user) { async userUpdated(user) {
if (this.user && this.user.id == user.id) { if (this.user && this.user.id == user.id) {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
} }
},
async userMediaProgressUpdated(prog) {
console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`)
// Update local media progress if exists
var localProg = this.$store.getters['globals/getLocalMediaProgressByServerItemId'](prog.libraryItemId, prog.episodeId)
var newLocalMediaProgress = null
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
// Server progress is more up-to-date
console.log(`[default] syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''}`)
const payload = {
localMediaProgressId: localProg.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
} else {
// Check if local library item exists
var localLibraryItem = await this.$db.getLocalLibraryItemByLLId(prog.libraryItemId)
if (localLibraryItem) {
if (prog.episodeId) {
// If episode check if local episode exists
var lliEpisodes = localLibraryItem.media.episodes || []
var localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
if (localEpisode) {
// Add new local media progress
const payload = {
localLibraryItemId: localLibraryItem.id,
localEpisodeId: localEpisode.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
}
} else {
// Add new local media progress
const payload = {
localLibraryItemId: localLibraryItem.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
}
} else {
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
}
}
if (newLocalMediaProgress && newLocalMediaProgress.id) {
console.log(`[default] local media progress updated for ${newLocalMediaProgress.id}`)
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
}
} }
}, },
async mounted() { async mounted() {
this.$socket.on('connection-update', this.socketConnectionUpdate) this.$socket.on('connection-update', this.socketConnectionUpdate)
this.$socket.on('initialized', this.socketInit) this.$socket.on('initialized', this.socketInit)
this.$socket.on('user_updated', this.userUpdated) this.$socket.on('user_updated', this.userUpdated)
this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated)
if (this.$store.state.isFirstLoad) { if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false) this.$store.commit('setIsFirstLoad', false)
@@ -253,7 +273,6 @@ export default {
console.log(`[default] finished connection attempt or already connected ${!!this.user}`) console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
await this.syncLocalMediaProgress() await this.syncLocalMediaProgress()
this.$store.dispatch('globals/loadLocalMediaProgress') this.$store.dispatch('globals/loadLocalMediaProgress')
this.checkForUpdate()
this.loadSavedSettings() this.loadSavedSettings()
this.hasMounted = true this.hasMounted = true
} }
@@ -262,6 +281,7 @@ export default {
this.$socket.off('connection-update', this.socketConnectionUpdate) this.$socket.off('connection-update', this.socketConnectionUpdate)
this.$socket.off('initialized', this.socketInit) this.$socket.off('initialized', this.socketInit)
this.$socket.off('user_updated', this.userUpdated) this.$socket.off('user_updated', this.userUpdated)
this.$socket.off('user_media_progress_updated', this.userMediaProgressUpdated)
} }
} }
</script> </script>
+1 -2
View File
@@ -30,8 +30,7 @@ export default {
} }
], ],
link: [ link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
] ]
}, },
+18766 -45
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,9 +1,9 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.41-beta", "version": "0.9.48-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname localhost --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
"build": "nuxt build", "build": "nuxt build",
"start": "nuxt start", "start": "nuxt start",
"generate": "nuxt generate", "generate": "nuxt generate",
@@ -23,7 +23,6 @@
"@capacitor/status-bar": "^1.0.8", "@capacitor/status-bar": "^1.0.8",
"@capacitor/storage": "^1.2.5", "@capacitor/storage": "^1.2.5",
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"@robingenz/capacitor-app-update": "^1.3.1",
"core-js": "^3.15.1", "core-js": "^3.15.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "^0.3.88",
-37
View File
@@ -19,19 +19,10 @@
</div> </div>
<p class="font-mono pt-1 pb-4">{{ $config.version }}</p> <p class="font-mono pt-1 pb-4">{{ $config.version }}</p>
<ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate">Update is available</ui-btn>
<ui-btn v-if="!isUpdateAvailable || immediateUpdateAllowed" class="w-full my-4" color="primary" @click="openAppStore">Open app store</ui-btn>
<p class="text-xs text-gray-400">UA: {{ updateAvailability }} | Avail: {{ availableVersion }} | Curr: {{ currentVersion }} | ImmedAllowed: {{ immediateUpdateAllowed }}</p>
</div> </div>
</template> </template>
<script> <script>
import { AppUpdate } from '@robingenz/capacitor-app-update'
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default { export default {
asyncData({ redirect, store }) { asyncData({ redirect, store }) {
if (!store.state.socketConnected) { if (!store.state.socketConnected) {
@@ -58,24 +49,6 @@ export default {
}, },
serverAddress() { serverAddress() {
return this.serverConnectionConfig.address return this.serverConnectionConfig.address
},
appUpdateInfo() {
return this.$store.state.appUpdateInfo
},
availableVersion() {
return this.appUpdateInfo ? this.appUpdateInfo.availableVersion : null
},
currentVersion() {
return this.appUpdateInfo ? this.appUpdateInfo.currentVersion : null
},
immediateUpdateAllowed() {
return this.appUpdateInfo ? !!this.appUpdateInfo.immediateUpdateAllowed : false
},
updateAvailability() {
return this.appUpdateInfo ? this.appUpdateInfo.updateAvailability : null
},
isUpdateAvailable() {
return this.updateAvailability === 2
} }
}, },
methods: { methods: {
@@ -85,16 +58,6 @@ export default {
}) })
this.$server.logout() this.$server.logout()
this.$router.push('/connect') this.$router.push('/connect')
},
openAppStore() {
AppUpdate.openAppStore()
},
async clickUpdate() {
if (this.immediateUpdateAllowed) {
AppUpdate.performImmediateUpdate()
} else {
AppUpdate.openAppStore()
}
} }
}, },
mounted() {} mounted() {}
+4 -2
View File
@@ -6,7 +6,7 @@
</template> </template>
</div> </div>
<div v-if="!shelves.length" class="absolute top-0 left-0 w-full h-full flex items-center justify-center"> <div v-if="!shelves.length && !loading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<div> <div>
<p class="mb-4 text-center text-xl"> <p class="mb-4 text-center text-xl">
Bookshelf empty Bookshelf empty
@@ -20,13 +20,15 @@
<span class="material-icons text-error text-lg">cloud_off</span> <span class="material-icons text-error text-lg">cloud_off</span>
<p class="pl-2 text-error text-sm">Audiobookshelf server not connected.</p> <p class="pl-2 text-error text-sm">Audiobookshelf server not connected.</p>
</div> </div>
<!-- <p class="px-4 text-center text-error absolute bottom-12 left-0 right-0 mx-auto"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p> -->
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<ui-btn v-if="!user" small @click="$router.push('/connect')" class="w-32">Connect</ui-btn> <ui-btn v-if="!user" small @click="$router.push('/connect')" class="w-32">Connect</ui-btn>
</div> </div>
</div> </div>
</div> </div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<ui-loading-indicator text="Loading Library..." />
</div>
</div> </div>
</template> </template>
-16
View File
@@ -1,16 +0,0 @@
<template>
<div class="w-full h-full">
<p class="text-xl text-center py-8">Under Construction...</p>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+248 -60
View File
@@ -1,53 +1,100 @@
<template> <template>
<div class="w-full h-full px-3 py-4 overflow-y-auto"> <div class="w-full h-full px-3 py-4 overflow-y-auto">
<div class="flex"> <div class="flex">
<div class="w-32"> <div class="w-16">
<div class="relative"> <div class="relative">
<covers-book-cover :library-item="libraryItem" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="64" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 128 * progressPercent + 'px' }"></div> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 64 * progressPercent + 'px' }"></div>
</div> </div>
</div> </div>
<div class="flex-grow px-3"> <div class="title-container flex-grow pl-2">
<h1 class="text-lg">{{ title }}</h1> <div class="flex relative pr-6">
<!-- <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> --> <h1 class="text-base">{{ title }}</h1>
<p class="text-sm text-gray-400">by {{ author }}</p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1"> <button class="absolute top-0 right-0 h-full px-1 outline-none" @click="moreButtonPress">
{{ $elapsedPretty(duration) }} <span class="material-icons text-xl">more_vert</span>
<span class="px-4">{{ $bytesPretty(size) }}</span> </button>
</div>
<p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
<template v-for="(series, index) in seriesList"
><nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`">{{ series.text }}</nuxt-link
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span></template
>
</p> </p>
<p v-if="podcastAuthor" class="text-sm text-gray-400 py-0.5">By {{ author }}</p>
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-400 py-0.5">
By
<template v-for="(author, index) in bookAuthors"
><nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`">{{ author.name }}</nuxt-link
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,&nbsp;</span></template
>
</p>
</div>
</div>
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''"> <p v-if="narrators && narrators.length" class="text-sm text-gray-400 py-0.5">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> Narrated By
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> <template v-for="(narrator, index) in narrators"
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> ><nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`">{{ narrator }}</nuxt-link
<span class="material-icons text-sm">close</span> ><span :key="`${narrator}-comma`" v-if="index < narrators.length - 1">,&nbsp;</span></template
</div> >
</div> </p>
<div v-if="isLocal" class="flex mt-4 -mr-2"> <!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick"> <p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p>
<span v-show="!isPlaying" class="material-icons">play_arrow</span> <p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play Local' }}</span>
</ui-btn> <div v-if="numTracks" class="flex text-gray-100 text-xs my-2 -mx-0.5">
<ui-btn v-if="showRead && isConnected" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook"> <div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<span class="material-icons">auto_stories</span> <p>{{ $elapsedPretty(duration) }}</p>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span> </div>
</ui-btn> <!-- TODO: Local books dont save the size -->
</div> <div v-if="size" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4 -mr-2"> <p>{{ $bytesPretty(size) }}</p>
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick"> </div>
<span v-show="!isPlaying" class="material-icons">play_arrow</span> <div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play Local' : 'Play Stream' }}</span> <p>{{ numTracks }} Track{{ numTracks > 1 ? 's' : '' }}</p>
</ui-btn> </div>
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook"> <div v-if="numChapters" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<span class="material-icons">auto_stories</span> <p>{{ numChapters }} Chapter{{ numChapters > 1 ? 's' : '' }}</p>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span> </div>
</ui-btn> </div>
<ui-btn v-if="user && showPlay && !isIos && !hasLocal" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span> <div>
</ui-btn> <div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div> </div>
</div> </div>
<div v-if="isLocal" class="flex mt-4">
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play' }}</span>
</ui-btn>
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play' : 'Stream' }}</span>
</ui-btn>
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mr-2" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
</div> </div>
<div v-if="downloadItem" class="py-3"> <div v-if="downloadItem" class="py-3">
@@ -61,6 +108,10 @@
<tables-podcast-episodes-table v-if="isPodcast" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" /> <tables-podcast-episodes-table v-if="isPodcast" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" /> <modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
<modals-dialog v-model="showMoreMenu" title="" :items="moreMenuItems" @action="moreMenuAction" />
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
</div> </div>
</template> </template>
@@ -75,7 +126,7 @@ export default {
console.log(libraryItemId) console.log(libraryItemId)
if (libraryItemId.startsWith('local')) { if (libraryItemId.startsWith('local')) {
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId) libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
console.log('Got lli', libraryItem) console.log('Got lli', libraryItemId)
} else if (store.state.user.serverConnectionConfig) { } else if (store.state.user.serverConnectionConfig) {
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => { libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
@@ -102,13 +153,19 @@ export default {
data() { data() {
return { return {
resettingProgress: false, resettingProgress: false,
showSelectLocalFolder: false isProcessingReadUpdate: false,
showSelectLocalFolder: false,
showMoreMenu: false,
showDetailsModal: false
} }
}, },
computed: { computed: {
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isLocal() { isLocal() {
return this.libraryItem.isLocal return this.libraryItem.isLocal
}, },
@@ -128,8 +185,14 @@ export default {
var podcastMedia = this.localLibraryItem.media var podcastMedia = this.localLibraryItem.media
return podcastMedia ? podcastMedia.episodes || [] : [] return podcastMedia ? podcastMedia.episodes || [] : []
}, },
isConnected() { serverLibraryItemId() {
return this.$store.state.socketConnected if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['getBookCoverAspectRatio']
@@ -152,9 +215,17 @@ export default {
title() { title() {
return this.mediaMetadata.title return this.mediaMetadata.title
}, },
author() { podcastAuthor() {
if (this.isPodcast) return this.mediaMetadata.author if (!this.isPodcast) return null
return this.mediaMetadata.authorName return this.mediaMetadata.author || ''
},
bookAuthors() {
if (this.isPodcast) return null
return this.mediaMetadata.authors || []
},
narrators() {
if (this.isPodcast) return null
return this.mediaMetadata.narrators || []
}, },
description() { description() {
return this.mediaMetadata.description || '' return this.mediaMetadata.description || ''
@@ -162,6 +233,17 @@ export default {
series() { series() {
return this.mediaMetadata.series || [] return this.mediaMetadata.series || []
}, },
seriesList() {
if (this.isPodcast) return null
return this.series.map((se) => {
var text = se.name
if (se.sequence) text += ` #${se.sequence}`
return {
...se,
text
}
})
},
duration() { duration() {
return this.media.duration return this.media.duration
}, },
@@ -205,6 +287,10 @@ export default {
if (!this.media.tracks) return 0 if (!this.media.tracks) return 0
return this.media.tracks.length || 0 return this.media.tracks.length || 0
}, },
numChapters() {
if (!this.media.chapters) return 0
return this.media.chapters.length || 0
},
isMissing() { isMissing() {
return this.libraryItem.isMissing return this.libraryItem.isMissing
}, },
@@ -217,6 +303,10 @@ export default {
showRead() { showRead() {
return this.ebookFile && this.ebookFormat !== 'pdf' return this.ebookFile && this.ebookFormat !== 'pdf'
}, },
showDownload() {
if (this.isIos) return false
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
},
ebookFile() { ebookFile() {
return this.media.ebookFile return this.media.ebookFile
}, },
@@ -224,23 +314,60 @@ export default {
if (!this.ebookFile) return null if (!this.ebookFile) return null
return this.ebookFile.ebookFormat return this.ebookFile.ebookFormat
}, },
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadItem() { downloadItem() {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId) return this.$store.getters['globals/getDownloadItem'](this.libraryItemId)
}, },
episodes() { episodes() {
return this.media.episodes || [] return this.media.episodes || []
},
isCasting() {
return this.$store.state.isCasting
},
moreMenuItems() {
if (this.isLocal) {
return [
{
text: 'Manage Local Files',
value: 'manageLocal'
},
{
text: 'View Details',
value: 'details'
}
]
} else {
return [
{
text: 'View Details',
value: 'details'
}
]
}
} }
}, },
methods: { methods: {
moreMenuAction(action) {
this.showMoreMenu = false
if (action === 'manageLocal') {
this.$router.push(`/localMedia/item/${this.libraryItemId}`)
} else if (action === 'details') {
this.showDetailsModal = true
}
},
moreButtonPress() {
this.showMoreMenu = true
},
readBook() { readBook() {
this.$store.commit('openReader', this.libraryItem) this.$store.commit('openReader', this.libraryItem)
}, },
playClick() { playClick() {
// Todo: Allow playing local or streaming // Todo: Allow playing local or streaming
if (this.hasLocal) return this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id }) if (this.hasLocal && this.serverLibraryItemId && this.isCasting) {
// If casting and connected to server for local library item then send server library item id
this.$eventBus.$emit('play-item', { libraryItemId: this.serverLibraryItemId })
return
}
if (this.hasLocal) return this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id, serverLibraryItemId: this.serverLibraryItemId })
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId }) this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
}, },
async clearProgressClick() { async clearProgressClick() {
@@ -253,7 +380,7 @@ export default {
if (this.isLocal) { if (this.isLocal) {
// TODO: If connected to server also sync with server // TODO: If connected to server also sync with server
await this.$db.removeLocalMediaProgress(this.libraryItemId) await this.$db.removeLocalMediaProgress(this.libraryItemId)
this.$store.commit('globals/removeLocalMediaProgress', this.libraryItemId) this.$store.commit('globals/removeLocalMediaProgressForItem', this.libraryItemId)
} else { } else {
var progressId = this.userItemProgress.id var progressId = this.userItemProgress.id
await this.$axios await this.$axios
@@ -293,13 +420,17 @@ export default {
if (this.downloadItem) { if (this.downloadItem) {
return return
} }
this.download()
},
async download(selectedLocalFolder = null) {
if (!this.numTracks) { if (!this.numTracks) {
return return
} }
if (this.isIos) {
// no local folders on iOS
this.startDownload()
} else {
this.download()
}
},
async download(selectedLocalFolder = null) {
// Get the local folder to download to // Get the local folder to download to
var localFolder = selectedLocalFolder var localFolder = selectedLocalFolder
if (!localFolder) { if (!localFolder) {
@@ -337,9 +468,15 @@ export default {
this.startDownload(localFolder) this.startDownload(localFolder)
} }
}, },
async startDownload(localFolder) { async startDownload(localFolder = null) {
console.log('Starting download to local folder', localFolder.name) const payload = {
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id }) libraryItemId: this.libraryItemId
}
if (localFolder) {
console.log('Starting download to local folder', localFolder.name)
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)
@@ -351,15 +488,66 @@ export default {
console.log('New local library item', item.id) console.log('New local library item', item.id)
this.$set(this.libraryItem, 'localLibraryItem', item) this.$set(this.libraryItem, 'localLibraryItem', item)
} }
},
async toggleFinished() {
this.isProcessingReadUpdate = true
if (this.isLocal) {
var isFinished = !this.userIsFinished
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) {
var errorMsg = payload ? payload.error : 'Unknown error'
this.$toast.error(errorMsg)
} else {
var localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
console.log('toggleFinished Check LMP', this.libraryItemId, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
}
this.isProcessingReadUpdate = false
} else {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
}
} }
}, },
mounted() { mounted() {
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.on('item_updated', this.itemUpdated) this.$socket.on('item_updated', this.itemUpdated)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.off('item_updated', this.itemUpdated) this.$socket.off('item_updated', this.itemUpdated)
} }
} }
</script> </script>
<style>
.title-container {
width: calc(100% - 64px);
max-width: calc(100% - 64px);
}
</style>
+2 -2
View File
@@ -189,7 +189,7 @@ export default {
if (this.selectedAudioTrack || this.selectedEpisode) { if (this.selectedAudioTrack || this.selectedEpisode) {
return [ return [
{ {
text: 'Hard Delete', text: 'Remove & Delete Files',
value: 'track-delete' value: 'track-delete'
} }
] ]
@@ -208,7 +208,7 @@ export default {
value: 'remove' value: 'remove'
}, },
{ {
text: 'Hard Delete', text: 'Remove & Delete Files',
value: 'delete' value: 'delete'
} }
] ]
+14 -1
View File
@@ -76,7 +76,11 @@ export default {
}, },
methods: { methods: {
async runSearch(value) { async runSearch(value) {
if (this.isFetching && this.lastSearch === value) return
this.lastSearch = value this.lastSearch = value
this.$store.commit('globals/setLastSearch', value)
if (!this.lastSearch) { if (!this.lastSearch) {
this.bookResults = [] this.bookResults = []
this.podcastResults = [] this.podcastResults = []
@@ -89,6 +93,10 @@ export default {
console.error('Search error', error) console.error('Search error', error)
return [] return []
}) })
if (value !== this.lastSearch) {
console.log(`runSearch: New search was made for ${this.lastSearch} - results are from ${value}`)
return
}
console.log('RESULTS', results) console.log('RESULTS', results)
this.isFetching = false this.isFetching = false
@@ -113,7 +121,12 @@ export default {
} }
}, },
mounted() { mounted() {
this.$nextTick(this.setFocus()) if (this.$store.state.globals.lastSearch) {
this.search = this.$store.state.globals.lastSearch
this.runSearch(this.search)
} else {
this.$nextTick(this.setFocus())
}
} }
} }
</script> </script>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="w-full h-full p-8">
<div class="flex items-center py-3" @click="toggleDisableAutoRewind">
<div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
</div>
<p class="pl-4">Disable Auto Rewind</p>
</div>
<div class="flex items-center py-3" @click="toggleJumpBackwards">
<div class="w-10 flex justify-center">
<span class="material-icons text-4xl">{{ currentJumpBackwardsTimeIcon }}</span>
</div>
<p class="pl-4">Jump backwards time</p>
</div>
<div class="flex items-center py-3" @click="toggleJumpForwards">
<div class="w-10 flex justify-center">
<span class="material-icons text-4xl">{{ currentJumpForwardsTimeIcon }}</span>
</div>
<p class="pl-4">Jump forwards time</p>
</div>
</div>
</template>
<script>
export default {
data() {
return {
settings: {
disableAutoRewind: false,
jumpForwardsTime: 10000,
jumpBackwardsTime: 10000
},
jumpForwardsItems: [
{
icon: 'forward_5',
value: 5000
},
{
icon: 'forward_10',
value: 10000
},
{
icon: 'forward_30',
value: 30000
}
],
jumpBackwardsItems: [
{
icon: 'replay_5',
value: 5000
},
{
icon: 'replay_10',
value: 10000
},
{
icon: 'replay_30',
value: 30000
}
]
}
},
computed: {
currentJumpForwardsTimeIcon() {
return this.jumpForwardsItems[this.currentJumpForwardsTimeIndex].icon
},
currentJumpForwardsTimeIndex() {
return this.jumpForwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpForwardsTime)
},
currentJumpBackwardsTimeIcon() {
return this.jumpBackwardsItems[this.currentJumpBackwardsTimeIndex].icon
},
currentJumpBackwardsTimeIndex() {
return this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
}
},
methods: {
toggleDisableAutoRewind() {
this.settings.disableAutoRewind = !this.settings.disableAutoRewind
this.saveSettings()
},
toggleJumpForwards() {
var next = (this.currentJumpForwardsTimeIndex + 1) % 3
this.settings.jumpForwardsTime = this.jumpForwardsItems[next].value
this.saveSettings()
},
toggleJumpBackwards() {
var next = (this.currentJumpBackwardsTimeIndex + 4) % 3
console.log('next', next)
if (next > 2) return
this.settings.jumpBackwardsTime = this.jumpBackwardsItems[next].value
this.saveSettings()
},
saveSettings() {
// TODO: Save settings
}
},
mounted() {
// TODO: Load settings
}
}
</script>
+2 -2
View File
@@ -187,6 +187,7 @@ class AbsAudioPlayerWeb extends WebPlugin {
return return
} }
this.player.currentTime = this.trackStartTime this.player.currentTime = this.trackStartTime
this.sendPlaybackMetadata(PlayerState.READY) this.sendPlaybackMetadata(PlayerState.READY)
if (this.playWhenReady) { if (this.playWhenReady) {
this.player.play() this.player.play()
@@ -195,10 +196,9 @@ class AbsAudioPlayerWeb extends WebPlugin {
evtTimeupdate() { } evtTimeupdate() { }
sendPlaybackMetadata(playerState) { sendPlaybackMetadata(playerState) {
var currentTime = this.player ? this.player.currentTime || 0 : 0
this.notifyListeners('onMetadata', { this.notifyListeners('onMetadata', {
duration: this.totalDuration, duration: this.totalDuration,
currentTime, currentTime: this.overallCurrentTime,
playerState playerState
}) })
} }
+6 -1
View File
@@ -195,11 +195,16 @@ class AbsDatabaseWeb extends WebPlugin {
return null return null
} }
async syncServerMediaProgressWithLocalMediaProgress(payload) {
return null
}
async updateLocalTrackOrder({ localLibraryItemId, tracks }) { async updateLocalTrackOrder({ localLibraryItemId, tracks }) {
return [] return []
} }
async updateLocalMediaProgressFinished({ localMediaProgressId, isFinished }) { async updateLocalMediaProgressFinished(payload) {
// { localLibraryItemId, localEpisodeId, isFinished }
return null return null
} }
} }
+4 -25
View File
@@ -1,33 +1,8 @@
import { Capacitor } from '@capacitor/core';
import { AbsDatabase } from './capacitor/AbsDatabase' import { AbsDatabase } from './capacitor/AbsDatabase'
const isWeb = Capacitor.getPlatform() == 'web'
class DbService { class DbService {
constructor() { } constructor() { }
// Please dont use this, it is not implemented in ios (maybe key: primary value: any ?)
save(db, key, value) {
if (isWeb) return
return AbsDatabase.saveFromWebview({ db, key, value }).then(() => {
console.log('Saved data', db, key, JSON.stringify(value))
}).catch((error) => {
console.error('Failed to save data', error)
})
}
// Please dont use this, it is not implemented in ios
load(db, key) {
if (isWeb) return null
return AbsDatabase.loadFromWebview({ db, key }).then((data) => {
console.log('Loaded data', db, key, JSON.stringify(data))
return data
}).catch((error) => {
console.error('Failed to load', error)
return null
})
}
getDeviceData() { getDeviceData() {
return AbsDatabase.getDeviceData().then((data) => { return AbsDatabase.getDeviceData().then((data) => {
console.log('Loaded device data', JSON.stringify(data)) console.log('Loaded device data', JSON.stringify(data))
@@ -95,6 +70,10 @@ class DbService {
return AbsDatabase.syncLocalMediaProgressWithServer() return AbsDatabase.syncLocalMediaProgressWithServer()
} }
syncServerMediaProgressWithLocalMediaProgress(payload) {
return AbsDatabase.syncServerMediaProgressWithLocalMediaProgress(payload)
}
updateLocalTrackOrder(payload) { updateLocalTrackOrder(payload) {
return AbsDatabase.updateLocalTrackOrder(payload) return AbsDatabase.updateLocalTrackOrder(payload)
} }
+5 -3
View File
@@ -48,9 +48,10 @@ class ServerSocket extends EventEmitter {
this.socket.on('user_updated', this.onUserUpdated.bind(this)) this.socket.on('user_updated', this.onUserUpdated.bind(this))
this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this)) this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this))
this.socket.onAny((evt, args) => { // Good for testing socket requests
console.log(`[SOCKET] onAny: ${this.socket.id}: ${evt} ${JSON.stringify(args)}`) // this.socket.onAny((evt, args) => {
}) // console.log(`[SOCKET] onAny: ${this.socket.id}: ${evt} ${JSON.stringify(args)}`)
// })
} }
onConnect() { onConnect() {
@@ -90,6 +91,7 @@ class ServerSocket extends EventEmitter {
console.log('[SOCKET] User Item Progress Updated', JSON.stringify(data)) console.log('[SOCKET] User Item Progress Updated', JSON.stringify(data))
var progress = data.data var progress = data.data
this.$store.commit('user/updateUserMediaProgress', progress) this.$store.commit('user/updateUserMediaProgress', progress)
this.emit('user_media_progress_updated', progress)
} }
} }
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name Source.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More