Compare commits

...
Author SHA1 Message Date
advplyr 84632c8a32 iOS version bump 0.9.61 2023-01-30 18:02:52 -06:00
advplyr df717fc87f Version bump 0.9.61-beta 2023-01-30 17:16:12 -06:00
advplyr 2b9f5c866b Fix home page shelves refreshing on network changes 2023-01-30 15:38:09 -06:00
advplyr 2de81130a1 Update:Podcast latest page show cover & podcast name #529 2023-01-30 11:39:01 -06:00
advplyr 2942d703ee Merge pull request #528 from blampe/seek
Handle skip track events on iOS
2023-01-30 08:18:01 -06:00
advplyr 3ef6c345e4 Fix Realm wrong thread crash by setting values from deviceSettings outside of commandCenter functions 2023-01-30 08:14:32 -06:00
advplyr 0a4052dec2 Fix manage local files redirect on iOS 2023-01-30 07:57:34 -06:00
Bryce Lampe ef4bb6c7a8 Handle skip track events on iOS 2023-01-29 19:54:12 -08:00
advplyr d9c38d1592 Update app bar progress indicator to not navigate to downloading on iOS 2023-01-29 17:53:03 -06:00
advplyr a7227acbc3 Update:Default to alt view and rename setting to use bookshelf view 2023-01-29 17:20:46 -06:00
advplyr bd5229941b Fix:Android sleep timer end of chapter use next chapter if within 2s of current chapter ending when reset #260 2023-01-29 17:11:15 -06:00
advplyr 0583ce617e Add:Continue listening shelf for offline books/episodes #112 2023-01-29 16:04:03 -06:00
advplyr 7462eb3bc4 Fix:iOS play audiobook tracks inside subfolders #483 2023-01-29 14:30:09 -06:00
advplyr 18e15ff056 Remove left & right padding of audio player timestamps on iOS 2023-01-29 14:12:14 -06:00
advplyr 4f6468c23a Merge pull request #519 from benonymity/playerUpdates
iOS Player Updates
2023-01-29 14:08:29 -06:00
advplyr 31531197a2 Add:Android auto sleep timer #260 2023-01-29 13:19:06 -06:00
advplyr 479de5f067 Android cleanup 2023-01-29 08:53:10 -06:00
advplyr 03240415b8 Update:Android sleep timer resets on play #260 2023-01-28 17:46:27 -06:00
advplyr f8a6b6a936 Update:Android sleep timer start fading out audio when 60s remaining on timer #320 #260 2023-01-28 17:33:15 -06:00
advplyr 28e50dd461 Add:Android device setting to adjust shake sensitivity for sleep timer #260 2023-01-28 17:31:52 -06:00
advplyr f06afbd08c Update:Sleep timer shake to reset anytime while timer is running #260 2023-01-28 16:07:03 -06:00
advplyr 860c7aac80 Fix:Refreshing server media progress after local sync 2023-01-28 14:20:00 -06:00
advplyr 8bab4ae383 Update:More accurate progress percentage using bytes, download 1 audio file at a time & currently downloading page #251 #360 #515 #274 2023-01-28 11:58:16 -06:00
advplyr 69171e5732 Update:Book downloads will go in author/title subfolders #194 2023-01-28 08:16:16 -06:00
Ben 31abaa019f Merge branch 'advplyr:master' into playerUpdates 2023-01-26 19:16:21 -05:00
advplyr ad57a90e4d Update iOS deployment target to 14 2023-01-26 17:35:37 -06:00
advplyr 925e8fd17e Update item page bg gradient 2023-01-26 17:14:06 -06:00
advplyr 7b857f04d4 Fix appbar z-index 2023-01-26 17:02:06 -06:00
advplyr 453744a637 Remove log 2023-01-25 17:48:31 -06:00
advplyr 062d4dc069 Fix:Render bookshelf on small screens 2023-01-25 17:32:52 -06:00
Ben 21bd98b367 Merge branch 'advplyr:master' into playerUpdates 2023-01-24 23:24:51 -05:00
advplyr ccd206e95d Fix cover height overflow on fullscreen audio player 2023-01-24 17:52:28 -06:00
benonymity bca5421dbc update: higher sync interval on low power mode 2023-01-24 17:35:12 -05:00
benonymity 20085c5eed update: increase update intervals 2023-01-24 17:34:53 -05:00
advplyr a8c66ff808 Update capacitor version, kotlin version, android dependencies, refactor some folders 2023-01-22 17:26:08 -06:00
advplyr b2d3edca81 Update:Android hide download notification when finished #495 2023-01-20 16:22:00 -06:00
advplyr 4b7fa7a543 Fix:Clearing filter refreshes library 2023-01-17 17:47:29 -06:00
advplyr a018dcf085 Update:Underline links on library item page 2023-01-17 17:38:48 -06:00
advplyr 091c1bcb9f Merge pull request #514 from lkiesow/toggle-total-track
Chapter and Total Track in Player
2023-01-17 15:50:59 -06:00
Lars Kiesow d89c97ba1a Chapter and Total Track in Player
This patch allows user to not only add a chapter track but also remove
the total track. This means that you can have only the total track or
only the chapter track.

This patch does not allow user to have no track at all. Disabling one
track will automatically enable the other one, if that one is already
disabled.

The reasoning behind this is that for long-ish audio books, users will
not very often look at the overall progress. In fact, I find the
progress bar mostly useful only for quickly seeking to a position when
jumping back a few seconds is not sufficient.

In the seldom occasion that I want the overall progress, I can easily
get it from the book details page.
2023-01-17 00:54:17 +01:00
advplyr 56fe350104 Update:Search input clearable #509 2023-01-16 17:44:32 -06:00
advplyr eb552170e3 Increase max playback speed to 10x 2023-01-16 17:34:42 -06:00
advplyr 29d022adcc Merge pull request #513 from lkiesow/player-menu-unified-style
Make Player Menu Items Look Alike
2023-01-16 17:04:02 -06:00
advplyr d9afdaf56f Update components/modals/Dialog.vue 2023-01-16 17:03:07 -06:00
Lars Kiesow 699976dcd8 Make Player Menu Items Look Alike
Instead of handling some items of the player menu as special cases,
adding icons and colors, this patch allows menu items to have icons in
general, allowing for the removal of the special cases.

This also makes it easy to actually use a homogeneous style for all of
the player menu items, letting them all have an icon and aligning them
visually.
2023-01-16 23:54:23 +01:00
advplyr acf85f4f09 Implement start time override 2023-01-15 16:33:51 -06:00
advplyr 8f6dd72df2 Local media sync events 2023-01-15 14:58:26 -06:00
advplyr 297eca6a86 MediaItemHistory and history page 2023-01-14 18:01:12 -06:00
advplyr b1805875b9 Update:Play/action button updates, show confirm dialog when mark as finished with progress 2023-01-13 17:30:48 -06:00
advplyr 16b06b5eb8 Fix bg gradient 2023-01-13 16:46:42 -06:00
advplyr dbbb5faa78 Fix button opacity 2023-01-12 17:12:14 -06:00
advplyr 5482b30324 Update:Play button color and increase cover size on item page 2023-01-12 17:04:47 -06:00
advplyr 5bae5411c8 Update:Library item page use bg gradient of primary cover image color 2023-01-11 18:00:05 -06:00
advplyr f75a8dfb86 Update:Redirect when changing libraries 2023-01-11 17:29:49 -06:00
advplyr 716d07c74e Fix:Open audio player covering up add new podcast button #502 2023-01-11 17:17:53 -06:00
advplyr 7de0304a3d Update:Audio player UI use primary cover image color as background gradient 2023-01-11 17:02:18 -06:00
advplyr df54094221 Fix:Android auto play downloaded media if available #385 2023-01-08 17:30:57 -06:00
advplyr 3207841384 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-01-08 16:04:20 -06:00
advplyr ae571d6b79 Merge pull request #499 from lkiesow/metadata-table
Metadata Table Alignment
2023-01-08 16:03:51 -06:00
advplyr f6ee0bf8df Fix:Lock orientation setting clicking on toggle button not working #465 2023-01-08 15:38:34 -06:00
advplyr d59f3ae0b6 Add:Haptic feedback device setting off/light/medium/heavy #472 2023-01-08 15:32:15 -06:00
advplyr 89041c4141 Fix:Show play button playing on local and non-local item pages #487 2023-01-08 14:50:21 -06:00
advplyr aac77949b4 Update:Audio player fullscreen move track above play button #408 #418 2023-01-08 14:31:37 -06:00
advplyr eb40f6e6e9 Fix:Library list view cover size #474 2023-01-08 14:07:48 -06:00
advplyr 34b3910ff8 Fix:Cover image for 1:1.6 covers in collapsed audio player #474 2023-01-08 13:50:18 -06:00
advplyr 83fdf4fd65 Update:Increase cover size on fullscreen audio player #491 2023-01-08 13:27:09 -06:00
Lars Kiesow ea6bb0a847 Metadata Table Alignment
This patch updates the metadata table in the book details view, turning
it into a CSS grid layout. This fixes a few issues with the current
layout:

- Keys and values are not vertically aligned with the value being
  displayed a little bit above the baseline of the key.
- There is no horizontal alignment of the table columns. In fact, it is
  technically not a table despite the CSS being tuned to almost look
  like one. Just turning this into a real grid looks cleaner.
- The code for making multi-line values not wrap is broken. This fixes
  the problem.

This also renames “Publish Year” to “Published”. Mainly because it is
shorter.
2023-01-08 17:17:59 +01:00
advplyr d85158bd70 Merge pull request #493 from lkiesow/chepter-idx
Make chapters identifiable
2023-01-05 16:57:02 -06:00
Lars Kiesow 22c5fee48e Make chapters identifiable
When long chapter names are being used, it may easily happen that parts
of the name is cut off. If this part identifies the latest chapter name
or number, it may make chapters indistinguishable from one another. This
makes it near imposible to select a specific chapter if the book has
many chapters.

This patch addresses the issue by starting each item in the list of
chapters with an index number.

This fixes #479
2023-01-05 23:07:11 +01:00
advplyr 0890a44c0b Update:Remove call to server for settings filter/sort/playback rate 2022-12-17 14:48:56 -06:00
110 changed files with 3804 additions and 20404 deletions
+6 -3
View File
@@ -22,6 +22,8 @@ kotlin {
android { android {
namespace 'com.audiobookshelf.app'
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
@@ -33,8 +35,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 91 versionCode 92
versionName "0.9.60-beta" versionName "0.9.61-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.
@@ -78,6 +80,7 @@ configurations.all {
} }
dependencies { dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation fileTree(include: ['*.jar'], dir: 'libs') implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android') implementation project(':capacitor-android')
@@ -121,7 +124,7 @@ dependencies {
implementation 'io.github.pilgr:paperdb:2.7.2' implementation 'io.github.pilgr:paperdb:2.7.2'
// Simple Storage // Simple Storage
implementation "com.anggrayudi:storage:0.14.0" implementation "com.anggrayudi:storage:1.5.4"
// OK HTTP // OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2' implementation 'com.squareup.okhttp3:okhttp:4.9.2'
+3 -3
View File
@@ -2,8 +2,8 @@
android { android {
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_11
} }
} }
@@ -13,8 +13,8 @@ dependencies {
implementation project(':capacitor-dialog') implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics') implementation project(':capacitor-haptics')
implementation project(':capacitor-network') implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar') implementation project(':capacitor-status-bar')
implementation project(':capacitor-storage')
} }
-1
View File
@@ -2,7 +2,6 @@
<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"
package="com.audiobookshelf.app"
android:installLocation="preferExternal" > android:installLocation="preferExternal" >
<!-- Permissions --> <!-- Permissions -->
@@ -16,11 +16,11 @@
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin" "classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
}, },
{ {
"pkg": "@capacitor/status-bar", "pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin" "classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
}, },
{ {
"pkg": "@capacitor/storage", "pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin" "classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
} }
] ]
@@ -12,8 +12,8 @@ import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.AbsDatabase import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.data.DbManager import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsDownloader
@@ -51,11 +51,17 @@ class MainActivity : BridgeActivity() {
// .detectLeakedClosableObjects() // .detectLeakedClosableObjects()
// .penaltyLog() // .penaltyLog()
// .build()) // .build())
DbManager.initialize(applicationContext)
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(tag, "onCreate") Log.d(tag, "onCreate")
DbManager.initialize(applicationContext)
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) { if (permission != PackageManager.PERMISSION_GRANTED) {
@@ -63,11 +69,6 @@ class MainActivity : BridgeActivity() {
PERMISSIONS_ALL, PERMISSIONS_ALL,
REQUEST_PERMISSIONS) REQUEST_PERMISSIONS)
} }
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -52,7 +52,8 @@ 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) {
val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null) val localEpisodeId = "local_ep_" + at.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
} }
} }
@@ -65,7 +66,8 @@ class Podcast(
} }
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { override fun addAudioTrack(audioTrack:AudioTrack) {
val newEpisode = PodcastEpisode("local_ep_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null) val localEpisodeId = "local_ep_" + audioTrack.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -93,7 +95,8 @@ class Podcast(
@JsonIgnore @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val newEpisode = PodcastEpisode("local_ep_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id) val localEpisodeId = "local_ep_" + episode.id
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -249,7 +252,8 @@ data class PodcastEpisode(
var audioTrack:AudioTrack?, var audioTrack:AudioTrack?,
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
var localEpisodeId:String? // For Android Auto server episodes with local copy
) { ) {
@JsonIgnore @JsonIgnore
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
@@ -260,6 +264,14 @@ data class PodcastEpisode(
} }
val extras = Bundle() val extras = Bundle()
if (localEpisodeId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) { if (progress != null) {
if (progress.isFinished) { if (progress.isFinished) {
extras.putInt( extras.putInt(
@@ -283,8 +295,9 @@ data class PodcastEpisode(
} }
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx) val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
val mediaId = localEpisodeId ?: id
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder() val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id) .setMediaId(mediaId)
.setTitle(title) .setTitle(title)
.setIconUri(coverUri) .setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title) .setSubtitle(libraryItemDescription.title)
@@ -357,26 +370,14 @@ data class BookChapter(
val endMs get() = (end * 1000L).toLong() val endMs get() = (end * 1000L).toLong()
} }
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
progress:Double, // 0 to 1
var currentTime:Double,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
) : MediaProgressWrapper(isFinished, progress)
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class) @JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
@JsonSubTypes( @JsonSubTypes(
JsonSubTypes.Type(MediaProgress::class), JsonSubTypes.Type(MediaProgress::class),
JsonSubTypes.Type(LocalMediaProgress::class) JsonSubTypes.Type(LocalMediaProgress::class)
) )
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double) open class MediaProgressWrapper(var isFinished:Boolean, var currentTime:Double, var progress:Double) {
open val mediaItemId get() = ""
}
// Helper class // Helper class
data class LibraryItemWithEpisode( data class LibraryItemWithEpisode(
@@ -2,6 +2,7 @@ package com.audiobookshelf.app.data
import android.content.Context import android.content.Context
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
@@ -11,6 +12,14 @@ enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE NONE, PORTRAIT, LANDSCAPE
} }
enum class HapticFeedbackSetting {
OFF, LIGHT, MEDIUM, HEAVY
}
enum class ShakeSensitivitySetting {
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
}
data class ServerConnectionConfig( data class ServerConnectionConfig(
var id:String, var id:String,
var index:Int, var index:Int,
@@ -22,48 +31,6 @@ data class ServerConnectionConfig(
var customHeaders:Map<String, String>? var customHeaders:Map<String, String>?
) )
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean,
var lockOrientation:LockOrientationSetting
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
disableShakeToResetSleepTimer = false,
lockOrientation = LockOrientationSetting.NONE
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
}
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
var deviceSettings:DeviceSettings?
) {
@JsonIgnore
fun getLastServerConnectionConfig():ServerConnectionConfig? {
return lastServerConnectionConfigId?.let { lsccid ->
return serverConnectionConfigs.find { it.id == lsccid }
}
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFile( data class LocalFile(
var id:String, var id:String,
@@ -121,3 +88,80 @@ data class PlayItemRequestPayload(
var forceTranscode:Boolean, var forceTranscode:Boolean,
var deviceInfo:DeviceInfo var deviceInfo:DeviceInfo
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean,
var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting,
var hapticFeedback: HapticFeedbackSetting,
var autoSleepTimer: Boolean,
var autoSleepTimerStartTime: String,
var autoSleepTimerEndTime: String,
var sleepTimerLength: Long // Time in milliseconds
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = true,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE,
hapticFeedback = HapticFeedbackSetting.LIGHT,
autoSleepTimer = false,
autoSleepTimerStartTime = "22:00",
autoSleepTimerEndTime = "06:00",
sleepTimerLength = 900000L // 15 minutes
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
@get:JsonIgnore
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
@get:JsonIgnore
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
@JsonIgnore
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
else {
Log.e("DeviceSetting", "Invalid ShakeSensitivitySetting $shakeSensitivity")
1.6f
}
}
}
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession: PlaybackSession?, // Stored to open up where left off for local media
var deviceSettings: DeviceSettings?
) {
@JsonIgnore
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
return lastServerConnectionConfigId?.let { lsccid ->
return serverConnectionConfigs.find { it.id == lsccid }
}
}
}
@@ -30,7 +30,8 @@ class LibraryItem(
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) var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads)
var localLibraryItemId:String? // For Android Auto
) : LibraryItemWrapper(id) { ) : LibraryItemWrapper(id) {
@get:JsonIgnore @get:JsonIgnore
val title get() = media.metadata.title val title get() = media.metadata.title
@@ -59,6 +60,13 @@ class LibraryItem(
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
val extras = Bundle() val extras = Bundle()
if (localLibraryItemId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) { if (progress != null) {
if (progress.isFinished) { if (progress.isFinished) {
extras.putInt( extras.putInt(
@@ -81,8 +89,9 @@ class LibraryItem(
) )
} }
val mediaId = localLibraryItemId ?: id
return MediaDescriptionCompat.Builder() return MediaDescriptionCompat.Builder()
.setMediaId(id) .setMediaId(mediaId)
.setTitle(title) .setTitle(title)
.setIconUri(getCoverUri()) .setIconUri(getCoverUri())
.setSubtitle(authorName) .setSubtitle(authorName)
@@ -14,6 +14,7 @@ import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
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.audiobookshelf.app.player.PLAYMETHOD_LOCAL
import java.util.* import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -117,6 +118,10 @@ class LocalLibraryItem(
} }
val extras = Bundle() val extras = Bundle()
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
if (progress != null) { if (progress != null) {
if (progress.isFinished) { if (progress.isFinished) {
extras.putInt( extras.putInt(
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.util.Log
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 kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -11,7 +12,7 @@ class LocalMediaProgress(
var localEpisodeId:String?, var localEpisodeId:String?,
var duration:Double, var duration:Double,
progress:Double, // 0 to 1 progress:Double, // 0 to 1
var currentTime:Double, currentTime:Double,
isFinished:Boolean, isFinished:Boolean,
var lastUpdate:Long, var lastUpdate:Long,
var startedAt:Long, var startedAt:Long,
@@ -22,9 +23,16 @@ class LocalMediaProgress(
var serverUserId:String?, var serverUserId:String?,
var libraryItemId:String?, var libraryItemId:String?,
var episodeId:String? var episodeId:String?
) : MediaProgressWrapper(isFinished, progress) { ) : MediaProgressWrapper(isFinished, currentTime, progress) {
@get:JsonIgnore @get:JsonIgnore
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt() val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@get:JsonIgnore
override val mediaItemId get() = if (libraryItemId != null) {
if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
} else {
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
}
@JsonIgnore @JsonIgnore
fun updateIsFinished(finished:Boolean) { fun updateIsFinished(finished:Boolean) {
@@ -41,8 +49,7 @@ class LocalMediaProgress(
fun updateFromPlaybackSession(playbackSession:PlaybackSession) { fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
currentTime = playbackSession.currentTime currentTime = playbackSession.currentTime
progress = playbackSession.progress progress = playbackSession.progress
lastUpdate = System.currentTimeMillis() lastUpdate = playbackSession.updatedAt
isFinished = playbackSession.progress >= 0.99 isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null finishedAt = if (isFinished) lastUpdate else null
} }
@@ -0,0 +1,15 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaItemEvent(
var name:String, // e.g. Play/Pause/Stop/Seek/Save
var type:String, // Playback/Info
var description:String?,
var currentTime:Number?, // Seconds
var serverSyncAttempted:Boolean?,
var serverSyncSuccess:Boolean?,
var serverSyncMessage:String?,
var timestamp: Long
)
@@ -0,0 +1,17 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaItemHistory(
var id: String, // media id
var mediaDisplayTitle: String,
var libraryItemId: String,
var episodeId: String?,
var isLocal:Boolean,
var serverConnectionConfigId:String?,
var serverAddress:String?,
var serverUserId:String?,
var createdAt: Long,
var events:MutableList<MediaItemEvent>,
)
@@ -0,0 +1,22 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
progress:Double, // 0 to 1
currentTime:Double,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
) : MediaProgressWrapper(isFinished, currentTime, progress) {
@get:JsonIgnore
override val mediaItemId get() = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
}
@@ -15,13 +15,7 @@ import com.google.android.exoplayer2.MediaMetadata
import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage import com.google.android.gms.common.images.WebImage
import com.audiobookshelf.app.player.*
// TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1
val PLAYMETHOD_TRANSCODE = 2
val PLAYMETHOD_LOCAL = 3
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession( class PlaybackSession(
@@ -65,11 +59,13 @@ class PlaybackSession(
@get:JsonIgnore @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 (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore @get:JsonIgnore
val progress get() = currentTime / getTotalDuration() val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore @get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@get:JsonIgnore
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
@JsonIgnore @JsonIgnore
fun getCurrentTrackIndex():Int { fun getCurrentTrackIndex():Int {
@@ -82,12 +78,41 @@ class PlaybackSession(
return audioTracks.size - 1 return audioTracks.size - 1
} }
@JsonIgnore
fun getNextTrackIndex():Int {
for (i in 0 until audioTracks.size) {
val track = audioTracks[i]
if (currentTimeMs < track.startOffsetMs) {
return i
}
}
return audioTracks.size - 1
}
@JsonIgnore @JsonIgnore
fun getChapterForTime(time:Long):BookChapter? { fun getChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null if (chapters.isEmpty()) return null
return chapters.find { time >= it.startMs && it.endMs > time} return chapters.find { time >= it.startMs && it.endMs > time}
} }
@JsonIgnore
fun getCurrentTrackEndTime():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
return currentTrack.startOffsetMs + currentTrack.durationMs
}
@JsonIgnore
fun getNextChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
return chapters.find { time < it.startMs } // First chapter where start time is > then time
}
@JsonIgnore
fun getNextTrackEndTime():Long {
val currentTrack = audioTracks[this.getNextTrackIndex()]
return currentTrack.startOffsetMs + currentTrack.durationMs
}
@JsonIgnore @JsonIgnore
fun getCurrentTrackTimeMs():Long { fun getCurrentTrackTimeMs():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()] val currentTrack = audioTracks[this.getCurrentTrackIndex()]
@@ -2,6 +2,7 @@ package com.audiobookshelf.app.device
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
interface WidgetEventEmitter { interface WidgetEventEmitter {
@@ -20,12 +21,21 @@ object DeviceManager {
val serverUserId get() = serverConnectionConfig?.userId ?: "" val serverUserId get() = serverConnectionConfig?.userId ?: ""
val token get() = serverConnectionConfig?.token ?: "" val token get() = serverConnectionConfig?.token ?: ""
val isConnectedToServer get() = serverConnectionConfig != null val isConnectedToServer get() = serverConnectionConfig != null
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
var widgetUpdater:WidgetEventEmitter? = null var widgetUpdater:WidgetEventEmitter? = null
init { init {
Log.d(tag, "Device Manager Singleton invoked") Log.d(tag, "Device Manager Singleton invoked")
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
deviceData.deviceSettings?.sleepTimerLength = 900000L
}
if (deviceData.deviceSettings?.shakeSensitivity == null) {
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
}
} }
fun getBase64Id(id:String):String { fun getBase64Id(id:String):String {
@@ -9,7 +9,7 @@ import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.Level import com.arthenica.ffmpegkit.Level
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.models.DownloadItem
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.fasterxml.jackson.module.kotlin.readValue
@@ -18,7 +18,7 @@ import org.json.JSONException
class FolderScanner(var ctx: Context) { class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?) data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
@@ -72,18 +72,18 @@ class FolderScanner(var ctx: Context) {
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}")
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) } val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
val result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe) when (scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)) {
ItemScanResult.REMOVED -> mediaItemsRemoved++
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++ ItemScanResult.UPDATED -> mediaItemsUpdated++
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++ ItemScanResult.ADDED -> mediaItemsAdded++
else if (result == ItemScanResult.ADDED) mediaItemsAdded++ else -> mediaItemsUpToDate++
else mediaItemsUpToDate++ }
} }
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date") Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) { return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items val folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems) FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
} else { } else {
Log.d(tag, "No Media Items to save") Log.d(tag, "No Media Items to save")
@@ -91,7 +91,7 @@ class FolderScanner(var ctx: Context) {
} }
} }
fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult { private fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
val itemFolderName = itemFolder.name ?: "" val itemFolderName = itemFolder.name ?: ""
val itemId = getLocalLibraryItemId(itemFolder.id) val itemId = getLocalLibraryItemId(itemFolder.id)
@@ -130,7 +130,7 @@ class FolderScanner(var ctx: Context) {
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}")
if (isAudio) { if (isAudio) {
var audioTrackToAdd:AudioTrack? = null val audioTrackToAdd:AudioTrack?
val 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
@@ -219,16 +219,18 @@ 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):DownloadItemScanResult? { fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf() val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = "" var itemFolderId = ""
var itemFolderUrl = "" var itemFolderUrl = ""
var itemFolderBasePath = "" var itemFolderBasePath = ""
var itemFolderAbsolutePath = "" var itemFolderAbsolutePath = ""
foldersFound.forEach { foldersFound.forEach {
if (it.name == downloadItem.itemTitle) { // e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
// and itemSubfolder is "Orson Scott Card/Enders Game"
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
itemFolderId = it.id itemFolderId = it.id
itemFolderUrl = it.uri.toString() itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx) itemFolderBasePath = it.getBasePath(ctx)
@@ -238,13 +240,13 @@ class FolderScanner(var ctx: Context) {
if (itemFolderUrl == "") { if (itemFolderUrl == "") {
Log.d(tag, "scanDownloadItem failed to find media folder") Log.d(tag, "scanDownloadItem failed to find media folder")
return null return cb(null)
} }
val 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 cb(null)
} }
val localLibraryItemId = getLocalLibraryItemId(itemFolderId) val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
@@ -256,7 +258,7 @@ class FolderScanner(var ctx: Context) {
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 localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem? = null var localLibraryItem:LocalLibraryItem?
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)
} else { } else {
@@ -280,17 +282,14 @@ class FolderScanner(var ctx: Context) {
} }
} else if (itemPart.audioTrack != null) { // Is audio track } else if (itemPart.audioTrack != null) { // Is audio track
val 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}")
val localFileId = DeviceManager.getBase64Id(docFile.id) val localFileId = DeviceManager.getBase64Id(docFile.id)
val 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
val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track // Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index) val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
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}")
@@ -314,7 +313,7 @@ class FolderScanner(var ctx: Context) {
if (audioTracks.isEmpty()) { if (audioTracks.isEmpty()) {
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}") Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
return null return cb(null)
} }
// For books sort audio tracks then set // For books sort audio tracks then set
@@ -364,7 +363,7 @@ class FolderScanner(var ctx: Context) {
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return downloadItemScanResult cb(downloadItemScanResult)
} }
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? { fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
@@ -401,7 +400,7 @@ class FolderScanner(var ctx: Context) {
localLibraryItem.localFiles.removeIf { it.id == localFileId } localLibraryItem.localFiles.removeIf { it.id == localFileId }
if (existingAudioTracks.find { it.localFileId == localFileId } != null) { if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
Log.d(tag, "scanLocalLibraryItem audio track file ${localFileId} was removed from ${localLibraryItem.absolutePath}") Log.d(tag, "scanLocalLibraryItem audio track file $localFileId was removed from ${localLibraryItem.absolutePath}")
localLibraryItem.media.removeAudioTrack(localFileId) localLibraryItem.media.removeAudioTrack(localFileId)
} }
wasUpdated = true wasUpdated = true
@@ -464,7 +463,7 @@ class FolderScanner(var ctx: Context) {
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem) return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
} }
fun probeAudioFile(absolutePath:String):AudioProbeResult? { private fun probeAudioFile(absolutePath:String):AudioProbeResult? {
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
var probeObject:JSObject? = null var probeObject:JSObject? = null
@@ -475,13 +474,13 @@ class FolderScanner(var ctx: Context) {
} }
Log.d(tag, "FFprobe output $probeObject") Log.d(tag, "FFprobe output $probeObject")
if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty return if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid") Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
return null null
} else { } else {
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output) val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
return audioProbeResult audioProbeResult
} }
} }
} }
@@ -1,8 +1,9 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.managers
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem
import io.paperdb.Paper import io.paperdb.Paper
import java.io.File import java.io.File
@@ -10,7 +11,7 @@ class DbManager {
val tag = "DbManager" val tag = "DbManager"
companion object { companion object {
var isDbInitialized = false private var isDbInitialized = false
fun initialize(ctx: Context) { fun initialize(ctx: Context) {
if (isDbInitialized) return if (isDbInitialized) return
@@ -107,7 +108,7 @@ class DbManager {
Paper.book("localFolders").delete(folderId) Paper.book("localFolders").delete(folderId)
} }
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) { fun saveDownloadItem(downloadItem: DownloadItem) {
Paper.book("downloadItems").write(downloadItem.id, downloadItem) Paper.book("downloadItems").write(downloadItem.id, downloadItem)
} }
@@ -115,10 +116,10 @@ class DbManager {
Paper.book("downloadItems").delete(downloadItemId) Paper.book("downloadItems").delete(downloadItemId)
} }
fun getDownloadItems():List<AbsDownloader.DownloadItem> { fun getDownloadItems():List<DownloadItem> {
val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf() val downloadItems:MutableList<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<DownloadItem>(downloadItemId)?.let {
downloadItems.add(it) downloadItems.add(it)
} }
} }
@@ -154,7 +155,7 @@ class DbManager {
val localLibraryItems = getLocalLibraryItems() val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli -> localLibraryItems.forEach { lli ->
var hasUpates = false var hasUpdates = false
// Check local files // Check local files
lli.localFiles = lli.localFiles.filter { localFile -> lli.localFiles = lli.localFiles.filter { localFile ->
@@ -162,7 +163,7 @@ class DbManager {
val 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 hasUpdates = true
} }
file.exists() file.exists()
} as MutableList<LocalFile> } as MutableList<LocalFile>
@@ -173,7 +174,7 @@ class DbManager {
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}")
hasUpates = true hasUpdates = true
} }
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>
@@ -182,7 +183,7 @@ class DbManager {
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}")
hasUpates = true hasUpdates = true
} }
lli.localFiles.find { lf -> lf.id == track.localFileId } != null lli.localFiles.find { lf -> lf.id == track.localFileId } != null
} as MutableList<AudioTrack> } as MutableList<AudioTrack>
@@ -196,11 +197,11 @@ class DbManager {
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}")
lli.coverAbsolutePath = null lli.coverAbsolutePath = null
lli.coverContentUrl = null lli.coverContentUrl = null
hasUpates = true hasUpdates = true
} }
} }
if (hasUpates) { if (hasUpdates) {
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}") Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
Paper.book("localLibraryItems").write(lli.id, lli) Paper.book("localLibraryItems").write(lli.id, lli)
} }
@@ -242,4 +243,12 @@ 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 saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
}
fun getMediaItemHistory(id:String): MediaItemHistory? {
return Paper.book("mediaItemHistory").read(id)
}
} }
@@ -0,0 +1,246 @@
package com.audiobookshelf.app.managers
import android.app.DownloadManager
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.MimeType
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.moveFileTo
import com.anggrayudi.storage.media.FileDescription
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
val tag = "DownloadItemManager"
private val maxSimultaneousDownloads = 1
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
enum class DownloadCheckStatus {
InProgress,
Successful,
Failed
}
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
interface DownloadEventEmitter {
fun onDownloadItem(downloadItem:DownloadItem)
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
fun onDownloadItemComplete(jsobj:JSObject)
}
companion object {
var isDownloading:Boolean = false
}
fun addDownloadItem(downloadItem:DownloadItem) {
DeviceManager.dbManager.saveDownloadItem(downloadItem)
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
downloadItemQueue.add(downloadItem)
clientEventEmitter.onDownloadItem(downloadItem)
checkUpdateDownloadQueue()
}
private fun checkUpdateDownloadQueue() {
for (downloadItem in downloadItemQueue) {
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
if (nextDownloadItemParts.size > 0) {
nextDownloadItemParts.forEach {
val dlRequest = it.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
it.downloadId = downloadId
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(it)
}
}
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
break
}
}
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
}
private fun startWatchingDownloads() {
if (isDownloading) return // Already watching
GlobalScope.launch(Dispatchers.IO) {
Log.d(tag, "Starting watching downloads")
isDownloading = true
while (currentDownloadItemParts.size > 0) {
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
for (downloadItemPart in itemParts) {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
}
delay(500)
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
checkUpdateDownloadQueue()
}
}
Log.d(tag, "Finished watching downloads")
isDownloading = false
}
}
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
val query = DownloadManager.Query().setFilterById(downloadId)
downloadManager.query(query).use {
if (it.moveToFirst()) {
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
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.getLong(bytesDownloadedColumnIndex) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
return when (downloadStatus) {
DownloadManager.STATUS_SUCCESSFUL -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
downloadItemPart.completed = true
downloadItemPart.progress = 1
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.Successful
}
DownloadManager.STATUS_FAILED -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItemPart.completed = true
downloadItemPart.failed = true
DownloadCheckStatus.Failed
}
else -> {
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
downloadItemPart.progress = percentProgress
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.InProgress
}
}
} else {
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
downloadItemPart.completed = true
downloadItemPart.failed = true
return DownloadCheckStatus.Failed
}
}
}
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
if (downloadItem == null) {
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
currentDownloadItemParts.remove(downloadItemPart)
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
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
downloadItemPart.isMoving = false
file?.delete()
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
override fun onCompleted(result:Any) {
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
val resultDocFile = result as DocumentFile
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
// Rename to fix appended .mp3 on m4b/m4a files
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
if (resultDocFile.name?.endsWith(".m4b.mp3") == true || resultDocFile.name?.endsWith(".m4a.mp3") == true) {
resultDocFile.renameTo(downloadItemPart.filename)
}
downloadItemPart.moved = true
downloadItemPart.isMoving = false
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
}
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
if (localFolderFile == null) {
// fAILED
downloadItemPart.failed = true
Log.e(tag, "Local Folder File from uri is null")
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
} else {
downloadItemPart.isMoving = true
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
}
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
}
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
if (downloadItem.isDownloadFinished) {
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
val jsobj = JSObject()
jsobj.put("libraryItemId", downloadItem.id)
jsobj.put("localFolderId", downloadItem.localFolder.id)
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
}
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
clientEventEmitter.onDownloadItemComplete(jsobj)
downloadItemQueue.remove(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
}
}
}
}
@@ -1,16 +1,17 @@
package com.audiobookshelf.app.player package com.audiobookshelf.app.managers
import android.content.Context import android.content.Context
import android.os.* import android.os.*
import android.util.Log import android.util.Log
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
import kotlin.math.roundToInt import kotlin.math.roundToInt
const val SLEEP_EXTENSION_TIME = 900000L // 15m class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
private val tag = "SleepTimerManager" private val tag = "SleepTimerManager"
private var sleepTimerTask:TimerTask? = null private var sleepTimerTask:TimerTask? = null
@@ -18,7 +19,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
private var sleepTimerEndTime:Long = 0L private var sleepTimerEndTime:Long = 0L
private var sleepTimerLength:Long = 0L private var sleepTimerLength:Long = 0L
private var sleepTimerElapsed:Long = 0L private var sleepTimerElapsed:Long = 0L
private var sleepTimerExtensionTime:Long = 0L
private var sleepTimerFinishedAt:Long = 0L private var sleepTimerFinishedAt:Long = 0L
private fun getCurrentTime():Long { private fun getCurrentTime():Long {
@@ -57,7 +57,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean { fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime") Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
sleepTimerTask?.cancel() sleepTimerTask?.cancel()
sleepTimerRunning = false sleepTimerRunning = true
sleepTimerFinishedAt = 0L sleepTimerFinishedAt = 0L
sleepTimerElapsed = 0L sleepTimerElapsed = 0L
@@ -72,7 +72,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
} }
sleepTimerEndTime = time sleepTimerEndTime = time
sleepTimerLength = 0 sleepTimerLength = 0
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
if (sleepTimerEndTime > getDuration()) { if (sleepTimerEndTime > getDuration()) {
sleepTimerEndTime = getDuration() sleepTimerEndTime = getDuration()
@@ -80,7 +79,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
} else { } else {
sleepTimerLength = time sleepTimerLength = time
sleepTimerEndTime = 0L sleepTimerEndTime = 0L
sleepTimerExtensionTime = time
if (sleepTimerLength + getCurrentTime() > getDuration()) { if (sleepTimerLength + getCurrentTime() > getDuration()) {
sleepTimerLength = getDuration() - getCurrentTime() sleepTimerLength = getDuration() - getCurrentTime()
@@ -89,9 +87,8 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
sleepTimerRunning = true
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) { sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
if (getIsPlaying()) { if (getIsPlaying()) {
sleepTimerElapsed += 1000L sleepTimerElapsed += 1000L
@@ -109,9 +106,9 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime()) playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer() clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis() sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) { } else if (sleepTimeSecondsRemaining <= 60) {
// Start fading out audio // Start fading out audio
val volume = sleepTimeSecondsRemaining / 30F val volume = sleepTimeSecondsRemaining / 60F
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining") Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume) setVolume(volume)
} }
@@ -121,7 +118,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
return true return true
} }
fun clearSleepTimer() { private fun clearSleepTimer() {
sleepTimerTask?.cancel() sleepTimerTask?.cancel()
sleepTimerTask = null sleepTimerTask = null
sleepTimerEndTime = 0 sleepTimerEndTime = 0
@@ -163,21 +160,40 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
} }
} }
private fun extendSleepTime() { // Get the chapter end time for use in End of Chapter timers
if (!sleepTimerRunning) return // if less than 2s remain in chapter then use the next chapter
setVolume(1F) private fun getChapterEndTime():Long? {
if (sleepTimerEndTime == 0L) { val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
sleepTimerLength += sleepTimerExtensionTime if (currentChapterEndTimeMs == null) {
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime() Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
return null
}
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
return if (timeLeftInChapter < 2000L) {
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
null
} else { } else {
sleepTimerEndTime += sleepTimerExtensionTime nextChapterEndTimeMs
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration() }
} else {
currentChapterEndTimeMs
}
} }
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) private fun resetChapterTimer() {
this.getChapterEndTime()?.let { chapterEndTime ->
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
vibrate()
setSleepTimer(chapterEndTime, true)
play()
}
} }
fun checkShouldExtendSleepTimer() { private fun checkShouldResetSleepTimer() {
if (!sleepTimerRunning) { if (!sleepTimerRunning) {
if (sleepTimerFinishedAt <= 0L) return if (sleepTimerFinishedAt <= 0L) return
@@ -189,17 +205,35 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
return return
} }
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME // Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time
if (sleepTimerLength == 0L) {
Log.d(tag, "Resetting stopped chapter sleep timer")
resetChapterTimer()
} else {
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
vibrate() vibrate()
setSleepTimer(newSleepTime, false) setSleepTimer(sleepTimerLength, false)
play() play()
}
return return
} }
// Only extend if within 30 seconds of finishing
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds() // Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
if (sleepTimeRemaining <= 30) { if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
vibrate() vibrate()
extendSleepTime() setSleepTimer(sleepTimerLength, false)
} else if (sleepTimerLength == 0L) {
// When navigating to previous chapters make sure this is still the end of the current chapter
this.getChapterEndTime()?.let { chapterEndTime ->
if (chapterEndTime != sleepTimerEndTime) {
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
vibrate()
setSleepTimer(chapterEndTime, true)
play()
}
}
} }
} }
@@ -209,7 +243,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
Log.d(tag, "Shake to reset sleep timer is disabled") Log.d(tag, "Shake to reset sleep timer is disabled")
return return
} }
checkShouldExtendSleepTimer() checkShouldResetSleepTimer()
} }
} }
@@ -254,4 +288,53 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
setVolume(1F) setVolume(1F)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
} }
fun checkAutoSleepTimer() {
if (sleepTimerRunning) { // Sleep timer already running
return
}
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
if (!deviceSettings.autoSleepTimer) return // Check auto sleep timer is enabled
val startCalendar = Calendar.getInstance()
startCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerStartHour)
startCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerStartMinute)
val endCalendar = Calendar.getInstance()
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
// In cases where end time is earlier then start time then we add a day to end time
// e.g. start time 22:00 and end time 06:00. End time will be treated as 6am the next day.
// e.g. start time 08:00 and end time 22:00. Start and end time will be the same day.
if (endCalendar.before(startCalendar)) {
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
}
val currentCalendar = Calendar.getInstance()
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
// Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time
if (deviceSettings.sleepTimerLength == 0L) {
val chapterEndTimeMs = this.getChapterEndTime()
if (chapterEndTimeMs == null) {
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
} else {
setSleepTimer(chapterEndTimeMs, true)
}
} else {
setSleepTimer(deviceSettings.sleepTimerLength, false)
}
} else {
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
}
}
}
fun handleMediaPlayEvent() {
checkShouldResetSleepTimer()
checkAutoSleepTimer()
}
} }
@@ -0,0 +1,113 @@
package com.audiobookshelf.app.media
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.player.SyncResult
object MediaEventManager {
const val tag = "MediaEventManager"
var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null
fun playEvent(playbackSession: PlaybackSession) {
Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Play", playbackSession, null)
}
fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Pause", playbackSession, syncResult)
}
fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Stop", playbackSession, syncResult)
}
fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Save", playbackSession, syncResult)
}
fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Finished", playbackSession, syncResult)
}
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
addPlaybackEvent("Seek", playbackSession, syncResult)
}
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
addSyncEvent("Sync", mediaProgress, description)
}
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
if (mediaItemHistory == null) {
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
return
}
val mediaItemEvent = MediaItemEvent(
name = eventName,
type = "Sync",
description = description,
currentTime = mediaProgress.currentTime,
serverSyncAttempted = false,
serverSyncSuccess = null,
serverSyncMessage = null,
timestamp = System.currentTimeMillis()
)
mediaItemHistory.events.add(mediaItemEvent)
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
}
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult:SyncResult?) {
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
val mediaItemEvent = MediaItemEvent(
name = eventName,
type = "Playback",
description = "",
currentTime = playbackSession.currentTime,
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
serverSyncSuccess = syncResult?.serverSyncSuccess,
serverSyncMessage = syncResult?.serverSyncMessage,
timestamp = System.currentTimeMillis()
)
mediaItemHistory.events.add(mediaItemEvent)
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
}
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
}
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
return MediaItemHistory(
id = playbackSession.mediaItemId,
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
libraryItemId,
episodeId,
isLocalOnly,
playbackSession.
serverConnectionConfigId,
playbackSession.serverAddress,
playbackSession.userId,
createdAt = System.currentTimeMillis(),
events = mutableListOf())
}
}
@@ -15,24 +15,24 @@ import org.json.JSONException
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager" val tag = "MediaManager"
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var selectedLibraryItems = mutableListOf<LibraryItem>() private var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = "" private var selectedLibraryId = ""
var selectedPodcast:Podcast? = null private var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null private var selectedLibraryItemId:String? = null
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>() private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>() private var serverLibraryCategories = listOf<LibraryCategory>()
private var serverConfigIdUsed:String? = null
private var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
var serverItemsInProgress = listOf<ItemInProgress>() var serverItemsInProgress = listOf<ItemInProgress>()
var serverLibraries = listOf<Library>() var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null
var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
var userSettingsPlaybackRate:Float? = null private var userSettingsPlaybackRate:Float? = null
fun getIsLibrary(id:String) : Boolean { fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null return serverLibraries.find { it.id == id } != null
@@ -76,7 +76,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) { private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
if (serverItemsInProgress.isNotEmpty()) { if (serverItemsInProgress.isNotEmpty()) {
cb(serverItemsInProgress) cb(serverItemsInProgress)
} else { } else {
@@ -109,7 +109,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) { private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
if (libraryItemId.startsWith("local")) { if (libraryItemId.startsWith("local")) {
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)) cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
} else { } else {
@@ -139,6 +139,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}") val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx) val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
@@ -157,6 +158,14 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val children = podcast.episodes?.map { podcastEpisode -> val children = podcast.episodes?.map { podcastEpisode ->
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id } val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
// to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemWrapper.id)
localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == podcastEpisode.id }
podcastEpisode.localEpisodeId = localEpisode?.id
}
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx) val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
@@ -167,7 +176,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadLibraries(cb: (List<Library>) -> Unit) { private fun loadLibraries(cb: (List<Library>) -> Unit) {
if (serverLibraries.isNotEmpty()) { if (serverLibraries.isNotEmpty()) {
cb(serverLibraries) cb(serverLibraries)
} else { } else {
@@ -178,9 +187,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean { private suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
var successfulPing = false var successfulPing = false
suspendCoroutine<Boolean> { cont -> suspendCoroutine { cont ->
apiHandler.pingServer(config) { apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it") Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it successfulPing = it
@@ -190,9 +199,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return successfulPing return successfulPing
} }
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> { private suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
var mediaProgress:MutableList<MediaProgress> = mutableListOf() var mediaProgress:MutableList<MediaProgress> = mutableListOf()
suspendCoroutine<MutableList<MediaProgress>> { cont -> suspendCoroutine { cont ->
apiHandler.authorize(config) { apiHandler.authorize(config) {
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it") Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) { if (!it.isNullOrEmpty()) {
@@ -204,7 +213,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return mediaProgress return mediaProgress
} }
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking { private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed") Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
coroutineScope { coroutineScope {
@@ -331,18 +340,18 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? { fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
if (id.startsWith("local")) { return if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id) DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else { } else {
return podcastEpisodeLibraryItemMap[id] podcastEpisodeLibraryItemMap[id]
} }
} }
fun getById(id:String) : LibraryItemWrapper? { fun getById(id:String) : LibraryItemWrapper? {
if (id.startsWith("local")) { return if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItem(id) DeviceManager.dbManager.getLocalLibraryItem(id)
} else { } else {
return serverLibraryItems.find { it.id == id } serverLibraryItems.find { it.id == id }
} }
} }
@@ -6,7 +6,9 @@ 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.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.data.Podcast
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
@@ -17,6 +19,12 @@ data class MediaProgressSyncData(
var currentTime:Double // seconds var currentTime:Double // seconds
) )
data class SyncResult(
var serverSyncAttempted:Boolean,
var serverSyncSuccess:Boolean?,
var serverSyncMessage:String?
)
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) { class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
private val tag = "MediaProgressSync" private val tag = "MediaProgressSync"
private val METERED_CONNECTION_SYNC_INTERVAL = 60000 private val METERED_CONNECTION_SYNC_INTERVAL = 60000
@@ -30,15 +38,15 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
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
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset" private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
val currentIsLocal get() = currentPlaybackSession?.isLocal == true private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
val currentSessionId get() = currentPlaybackSession?.id ?: "" val currentSessionId get() = currentPlaybackSession?.id ?: ""
val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0 private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start() { fun start(playbackSession:PlaybackSession) {
if (listeningTimerRunning) { if (listeningTimerRunning) {
Log.d(tag, "start: Timer already running for $currentDisplayTitle") Log.d(tag, "start: Timer already running for $currentDisplayTitle")
if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) { if (playbackSession.id != currentSessionId) {
Log.d(tag, "Playback session changed, reset timer") Log.d(tag, "Playback session changed, reset timer")
currentLocalMediaProgress = null currentLocalMediaProgress = null
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
@@ -48,25 +56,32 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
} else { } else {
return return
} }
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) { } else if (playbackSession.id != currentSessionId) {
currentLocalMediaProgress = null currentLocalMediaProgress = null
} }
listeningTimerRunning = true listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis() lastSyncTime = System.currentTimeMillis()
Log.d(tag, "start: init last sync time $lastSyncTime") Log.d(tag, "start: init last sync time $lastSyncTime")
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy() currentPlaybackSession = playbackSession.clone()
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) { if (playerNotificationService.currentPlayer.isPlaying) {
// Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s // Set auto sleep timer if enabled and within start/end time
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
val currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) { if (currentTime > 0) {
sync(shouldSyncServer, currentTime) { sync(shouldSyncServer, currentTime) { syncResult ->
Log.d(tag, "Sync complete") Log.d(tag, "Sync complete")
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.saveEvent(playbackSession, syncResult)
}
} }
} }
} }
@@ -74,8 +89,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
} }
} }
fun play(playbackSession:PlaybackSession) {
Log.d(tag, "play ${playbackSession.displayTitle}")
MediaEventManager.playEvent(playbackSession)
start(playbackSession)
}
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) { fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
if (!listeningTimerRunning) return if (!listeningTimerRunning) {
reset()
return cb()
}
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
listeningTimerTask = null listeningTimerTask = null
listeningTimerRunning = false listeningTimerRunning = false
@@ -83,11 +109,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0 val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
if (currentTime > 0) { // Current time should always be > 0 on stop if (currentTime > 0) { // Current time should always be > 0 on stop
sync(true, currentTime) { sync(true, currentTime) { syncResult ->
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.stopEvent(playbackSession, syncResult)
}
reset() reset()
cb() cb()
} }
} else { } else {
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.stopEvent(playbackSession, null)
}
reset() reset()
cb() cb()
} }
@@ -95,6 +129,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
fun pause(cb: () -> Unit) { fun pause(cb: () -> Unit) {
if (!listeningTimerRunning) return if (!listeningTimerRunning) return
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
listeningTimerTask = null listeningTimerTask = null
listeningTimerRunning = false listeningTimerRunning = false
@@ -103,51 +138,82 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
val currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) { // Current time should always be > 0 on pause if (currentTime > 0) { // Current time should always be > 0 on pause
sync(true, currentTime) { sync(true, currentTime) { syncResult ->
lastSyncTime = 0L lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime") Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
failedSyncs = 0 failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, syncResult)
}
cb() cb()
} }
} else { } else {
lastSyncTime = 0L lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)") Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
failedSyncs = 0 failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, null)
}
cb() cb()
} }
} }
fun finished(cb: () -> Unit) { fun finished(cb: () -> Unit) {
if (!listeningTimerRunning) return if (!listeningTimerRunning) return
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
listeningTimerTask = null listeningTimerTask = null
listeningTimerRunning = false listeningTimerRunning = false
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") Log.d(tag, "finished: Stopping listening for $currentDisplayTitle")
sync(true, currentPlaybackSession?.duration ?: 0.0) { sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult ->
reset() reset()
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.finishedEvent(playbackSession, syncResult)
}
cb() cb()
} }
} }
fun seek() {
currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds()
Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}")
if (currentPlaybackSession == null) {
Log.e(tag, "seek: Playback session not set")
return
}
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
}
fun syncFromServerProgress(mediaProgress: MediaProgress) { fun syncFromServerProgress(mediaProgress: MediaProgress) {
currentPlaybackSession?.let { currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime it.currentTime = mediaProgress.currentTime
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
DeviceManager.dbManager.saveLocalPlaybackSession(it) DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it) saveLocalProgress(it)
} }
} }
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) { fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
if (lastSyncTime <= 0) { if (lastSyncTime <= 0) {
Log.e(tag, "Last sync time is not set $lastSyncTime") Log.e(tag, "Last sync time is not set $lastSyncTime")
return return cb(null)
} }
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) { if (diffSinceLastSync < 1000L) {
return cb() return cb(null)
} }
val listeningTimeToAdd = diffSinceLastSync / 1000L val listeningTimeToAdd = diffSinceLastSync / 1000L
@@ -157,7 +223,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
if (currentPlaybackSession?.progress?.isNaN() == true) { if (currentPlaybackSession?.progress?.isNaN() == true) {
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}") Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
return cb() return cb(null)
} }
if (currentIsLocal) { if (currentIsLocal) {
@@ -170,11 +236,12 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
// Local library item is linked to a server library item // Local library item is linked to a server library item
// Send sync to server also if connected to this server and local item belongs to this server // Send sync to server also if connected to this server and local item belongs to this server
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess -> apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
Log.d( Log.d(
tag, tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime" "Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
) )
if (syncSuccess) { if (syncSuccess) {
failedSyncs = 0 failedSyncs = 0
playerNotificationService.alertSyncSuccess() playerNotificationService.alertSyncSuccess()
@@ -187,15 +254,16 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime") Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
} }
cb() cb(SyncResult(true, syncSuccess, errorMsg))
} }
} else { } else {
cb() cb(SyncResult(false, null, null))
} }
} }
} else if (shouldSyncServer) { } else if (shouldSyncServer) {
apiHandler.sendProgressSync(currentSessionId, syncData) { Log.d(tag, "sync: currentSessionId=$currentSessionId")
if (it) { apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
if (syncSuccess) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime") Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0 failedSyncs = 0
playerNotificationService.alertSyncSuccess() playerNotificationService.alertSyncSuccess()
@@ -208,10 +276,10 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
} }
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime") Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
} }
cb() cb(SyncResult(true, syncSuccess, errorMsg))
} }
} else { } else {
cb() cb(SyncResult(false, null, null))
} }
} }
@@ -222,17 +290,18 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress() currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else { } else {
currentLocalMediaProgress = mediaProgress currentLocalMediaProgress = mediaProgress
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
} }
} else { } else {
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession) currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
} }
currentLocalMediaProgress?.let { currentLocalMediaProgress?.let {
if (it.progress.isNaN()) { if (it.progress.isNaN()) {
Log.e(tag, "Invalid progress on local media progress") Log.e(tag, "Invalid progress on local media progress")
} else { } else {
DeviceManager.dbManager.saveLocalMediaProgress(it) DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%") Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
} }
} }
@@ -0,0 +1,41 @@
package com.audiobookshelf.app.models
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.MediaType
import com.fasterxml.jackson.annotation.JsonIgnore
data class DownloadItem(
val id: String,
val libraryItemId:String,
val episodeId:String?,
val userMediaProgress: MediaProgress?,
val serverConnectionConfigId:String,
val serverAddress:String,
val serverUserId:String,
val mediaType: String,
val itemFolderPath:String,
val localFolder: LocalFolder,
val itemTitle: String,
val itemSubfolder: String,
val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
) {
@get:JsonIgnore
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
@JsonIgnore
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
val itemParts = mutableListOf<DownloadItemPart>()
if (limit == 0) return itemParts
for (it in downloadItemParts) {
if (!it.completed && it.downloadId == null) {
itemParts.add(it)
if (itemParts.size > limit) break
}
}
return itemParts
}
}
@@ -0,0 +1,82 @@
package com.audiobookshelf.app.models
import android.app.DownloadManager
import android.net.Uri
import android.util.Log
import com.audiobookshelf.app.data.AudioTrack
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.PodcastEpisode
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import java.io.File
data class DownloadItemPart(
val id: String,
val downloadItemId: String,
val filename: String,
val fileSize: Long,
val finalDestinationPath:String,
val serverPath: String,
val localFolderName: String,
val localFolderUrl: String,
val localFolderId: String,
val audioTrack: AudioTrack?,
val episode: PodcastEpisode?,
var completed:Boolean,
var moved:Boolean,
var isMoving:Boolean,
var failed:Boolean,
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
val finalDestinationSubfolder: String,
var downloadId: Long?,
var progress: Long,
var bytesDownloaded: Long
) {
companion object {
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder: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),
downloadItemId,
filename = filename,
fileSize = fileSize,
finalDestinationPath = finalDestinationFile.absolutePath,
serverPath = serverPath,
localFolderName = localFolder.name,
localFolderUrl = localFolder.contentUrl,
localFolderId = localFolder.id,
audioTrack = audioTrack,
episode = episode,
completed = false,
moved = false,
isMoving = false,
failed = false,
uri = downloadUri,
destinationUri = destinationUri,
finalDestinationUri = finalDestinationUri,
finalDestinationSubfolder = subfolder,
downloadId = null,
progress = 0,
bytesDownloaded = 0
)
}
}
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
}
@@ -10,19 +10,17 @@ import android.support.v4.media.session.MediaControllerCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.* import kotlinx.coroutines.*
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter { class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
private val tag = "MediaDescriptionAdapter" private val tag = "MediaDescriptionAdapter"
var currentIconUri: Uri? = null private var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null private var currentBitmap: Bitmap? = null
private val serviceJob = SupervisorJob() private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@@ -48,6 +46,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
if (currentIconUri.toString().startsWith("content://")) { if (currentIconUri.toString().startsWith("content://")) {
currentBitmap = if (Build.VERSION.SDK_INT < 28) { currentBitmap = if (Build.VERSION.SDK_INT < 28) {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri) MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
} else { } else {
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!) val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
@@ -17,11 +17,10 @@ class BrowseTree(
/** /**
* get uri to drawable or any other resource type if u wish * get uri to drawable or any other resource type if u wish
* @param context - context
* @param drawableId - drawable res id * @param drawableId - drawable res id
* @return - uri * @return - uri
*/ */
fun getUriToDrawable(@AnyRes drawableId: Int): Uri { private fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId) + "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId) + '/' + context.resources.getResourceTypeName(drawableId)
@@ -49,7 +48,7 @@ class BrowseTree(
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
}.build() }.build()
if (!itemsInProgress.isEmpty()) { if (itemsInProgress.isNotEmpty()) {
rootList += continueListeningMetadata rootList += continueListeningMetadata
} }
@@ -2,10 +2,7 @@ package com.audiobookshelf.app.player
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.*
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
@@ -18,7 +15,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
var tag = "MediaSessionCallback" var tag = "MediaSessionCallback"
private var mediaButtonClickCount: Int = 0 private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms private var mediaButtonClickTimeout: Long = 1000 //ms
override fun onPrepare() { override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
@@ -28,7 +25,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item") Log.e(tag, "Failed to play library item")
} else { } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it,true, playbackRate) playerNotificationService.preparePlayer(it,true, playbackRate)
} }
} }
@@ -54,7 +51,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item") Log.e(tag, "Failed to play library item")
} else { } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate) playerNotificationService.preparePlayer(it, true, playbackRate)
} }
} }
@@ -93,7 +90,7 @@ 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 val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null var podcastEpisode: PodcastEpisode? = null
if (mediaId.isNullOrEmpty()) { if (mediaId.isNullOrEmpty()) {
@@ -116,7 +113,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item") Log.e(tag, "Failed to play library item")
} else { } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate) playerNotificationService.preparePlayer(it, true, playbackRate)
} }
} }
@@ -128,11 +125,17 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return handleCallMediaButton(mediaButtonEvent) return handleCallMediaButton(mediaButtonEvent)
} }
fun handleCallMediaButton(intent: Intent): Boolean { private fun handleCallMediaButton(intent: Intent): Boolean {
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}") Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
if(Intent.ACTION_MEDIA_BUTTON == intent.action) { if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) val keyEvent = if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)
}
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}") Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
if (keyEvent?.action == KeyEvent.ACTION_DOWN) { if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
@@ -166,7 +169,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.d(tag, "handleCallMediaButton: Media Play") Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
playerNotificationService.play() playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
} }
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
@@ -198,7 +200,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} else { } else {
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
playerNotificationService.play() playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
} }
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
@@ -35,7 +35,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item") Log.e(tag, "Failed to play library item")
} else { } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
} }
} }
@@ -46,7 +46,7 @@ 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? = null val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null var podcastEpisode: PodcastEpisode? = null
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
@@ -63,7 +63,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item") Log.e(tag, "Failed to play library item")
} else { } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
} }
} }
@@ -79,7 +79,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item") Log.e(tag, "Failed to play library item")
} else { } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
} }
} }
@@ -4,3 +4,17 @@ const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_for
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward"; const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward"; const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward"; const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
const val PLAYMETHOD_DIRECTPLAY = 0
const val PLAYMETHOD_DIRECTSTREAM = 1
const val PLAYMETHOD_TRANSCODE = 2
const val PLAYMETHOD_LOCAL = 3
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
const val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
const val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
@@ -1,6 +1,7 @@
package com.audiobookshelf.app.player package com.audiobookshelf.app.player
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.data.PlayerState import com.audiobookshelf.app.data.PlayerState
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackException
@@ -13,16 +14,101 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
companion object { companion object {
var lastPauseTime: Long = 0 //ms var lastPauseTime: Long = 0 //ms
var lazyIsPlaying: Boolean = false
} }
private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
val 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
} }
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
// If playing set seeking flag
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK")
playerNotificationService.mediaProgressSyncer.seek()
lastPauseTime = 0 // When seeking while paused reset the auto-rewind
} else {
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason")
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()} | playbackState=${playerNotificationService.currentPlayer.playbackState}")
val player = playerNotificationService.currentPlayer
// Goal of these 2 if statements and the lazyIsPlaying is to ignore this event when it is triggered by a seek
// When a seek occurs the player is paused and buffering, then plays again right afterwards.
if (!isPlaying && player.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "onIsPlayingChanged: Pause event when buffering is ignored")
return
}
if (lazyIsPlaying == isPlaying) {
Log.d(tag, "onIsPlayingChanged: Lazy is playing $lazyIsPlaying is already set to this so ignoring")
return
}
lazyIsPlaying = isPlaying
if (isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var seekBackTime = calcPauseSeekBackTime()
if (seekBackTime > 0) {
// Current chapter is used so that seek back does not go back to the previous chapter
val currentChapter = playerNotificationService.getCurrentBookChapter()
val minSeekBackTime = currentChapter?.startMs ?: 0
val currentTime = playerNotificationService.getCurrentTime()
val newTime = currentTime - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
}
// Check if playback session still exists or sync media progress if updated
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
if (!shouldCarryOn) return
}
if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime)
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time | playbackState=${player.playbackState}")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval
if (isPlaying) {
// Handles auto-starting sleep timer and resetting sleep timer
playerNotificationService.sleepTimerManager.handleMediaPlayEvent()
player.volume = 1F // Volume on sleep timer might have decreased this
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) }
} else {
playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
}
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}") Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
@@ -41,7 +127,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration) Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration)
if (lastPauseTime == 0L) { if (lastPauseTime == 0L) {
lastPauseTime = -1; lastPauseTime = -1
} }
playerNotificationService.sendClientMetadata(PlayerState.READY) playerNotificationService.sendClientMetadata(PlayerState.READY)
} }
@@ -67,62 +153,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
} }
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}")
if (player.isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
var seekBackTime = 0L
if (onSeekBack) onSeekBack = false
else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
seekBackTime = calcPauseSeekBackTime()
if (seekBackTime > 0) {
// Current chapter is used so that seek back does not go back to the previous chapter
val currentChapter = playerNotificationService.getCurrentBookChapter()
val minSeekBackTime = currentChapter?.startMs ?: 0
val currentTime = playerNotificationService.getCurrentTime()
val newTime = currentTime - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
onSeekBack = true
}
}
// Check if playback session still exists or sync media progress if updated
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
if (!shouldCarryOn) return
}
if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime)
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval
if (player.isPlaying) {
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.start()
} else {
playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
}
} }
private fun calcPauseSeekBackTime() : Long { private fun calcPauseSeekBackTime() : Long {
@@ -31,9 +31,10 @@ import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.managers.SleepTimerManager
import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
@@ -49,8 +50,8 @@ import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
const val PLAYER_CAST = "cast-player"; const val PLAYER_CAST = "cast-player"
const val PLAYER_EXO = "exo-player"; const val PLAYER_EXO = "exo-player"
class PlayerNotificationService : MediaBrowserServiceCompat() { class PlayerNotificationService : MediaBrowserServiceCompat() {
@@ -61,6 +62,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
} }
private val tag = "PlayerNotificationService"
interface ClientEventEmitter { interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession) fun onPlaybackSession(playbackSession:PlaybackSession)
fun onPlaybackClosed() fun onPlaybackClosed()
@@ -74,9 +77,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun onProgressSyncFailing() fun onProgressSyncFailing()
fun onProgressSyncSuccess() fun onProgressSyncSuccess()
fun onNetworkMeteredChanged(isUnmetered:Boolean) fun onNetworkMeteredChanged(isUnmetered:Boolean)
fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory)
} }
private val tag = "PlayerService"
private val binder = LocalBinder() private val binder = LocalBinder()
var clientEventEmitter:ClientEventEmitter? = null var clientEventEmitter:ClientEventEmitter? = null
@@ -101,7 +103,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
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 var currentPlaybackSession:PlaybackSession? = null
private var initialPlaybackRate:Float? = null private var initialPlaybackRate:Float? = null
private var isAndroidAuto = false private var isAndroidAuto = false
@@ -139,6 +141,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return START_STICKY return START_STICKY
} }
@Deprecated("Deprecated in Java")
override fun onStart(intent: Intent?, startId: Int) { override fun onStart(intent: Intent?, startId: Int) {
Log.d(tag, "onStart $startId") Log.d(tag, "onStart $startId")
} }
@@ -227,7 +230,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.apply { .apply {
setSessionActivity(sessionActivityPendingIntent) setSessionActivity(sessionActivityPendingIntent)
isActive = true isActive = true
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
} }
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@@ -392,7 +394,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
SkipForwardCustomActionProvider(), SkipForwardCustomActionProvider(),
)) ))
} }
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray()); mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
@@ -638,14 +640,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.clone() return currentPlaybackSession?.clone()
} }
fun getCurrentPlaybackSessionId() :String? {
return currentPlaybackSession?.id
}
fun getCurrentBookChapter():BookChapter? { fun getCurrentBookChapter():BookChapter? {
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime()) return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
} }
fun getEndTimeOfChapterOrTrack():Long? {
return getCurrentBookChapter()?.endMs ?: currentPlaybackSession?.getCurrentTrackEndTime()
}
private fun getNextBookChapter():BookChapter? {
return currentPlaybackSession?.getNextChapterForTime(this.getCurrentTime())
}
fun getEndTimeOfNextChapterOrTrack():Long? {
return getNextBookChapter()?.endMs ?: currentPlaybackSession?.getNextTrackEndTime()
}
// Called from PlayerListener play event // Called from PlayerListener play event
// check with server if progress has updated since last play and sync progress update // check with server if progress has updated since last play and sync progress update
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean { fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
@@ -682,7 +692,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
seekPlayer(playbackSession.currentTimeMs) seekPlayer(playbackSession.currentTimeMs)
// Should already be playing // Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start() currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
clientEventEmitter?.onPlayingUpdate(true) clientEventEmitter?.onPlayingUpdate(true)
} }
} else { } else {
@@ -690,9 +700,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (seekBackTime > 0L) { if (seekBackTime > 0L) {
seekBackward(seekBackTime) seekBackward(seekBackTime)
} }
// Should already be playing // Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start() currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
clientEventEmitter?.onPlayingUpdate(true) clientEventEmitter?.onPlayingUpdate(true)
} }
} }
@@ -722,7 +733,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start() mediaProgressSyncer.play(it)
clientEventEmitter?.onPlayingUpdate(true) clientEventEmitter?.onPlayingUpdate(true)
} }
} }
@@ -759,10 +770,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var timeToSeek = time var timeToSeek = time
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek") Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek")
if (timeToSeek < 0) { if (timeToSeek < 0) {
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to 0") Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to 0")
timeToSeek = 0L timeToSeek = 0L
} else if (timeToSeek > getDuration()) { } else if (timeToSeek > getDuration()) {
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to MAX - 2000") Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to MAX - 2000")
timeToSeek = getDuration() - 2000L timeToSeek = getDuration() - 2000L
} }
@@ -821,10 +832,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
currentPlaybackSession = null currentPlaybackSession = null
mediaProgressSyncer.reset()
clientEventEmitter?.onPlaybackClosed() clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0 PlayerListener.lastPauseTime = 0
isClosed = true isClosed = true
stopForeground(true) stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
@@ -848,8 +860,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME) return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
} }
@get:JsonIgnore private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload { fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo()) return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
@@ -870,11 +881,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // MEDIA BROWSER STUFF (ANDROID AUTO)
// //
private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val AUTO_MEDIA_ROOT = "/" private val AUTO_MEDIA_ROOT = "/"
@@ -939,6 +945,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else { } else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
libraryItem.localLibraryItemId = localLibraryItem?.id
val description = libraryItem.getMediaDescription(progress, ctx) val description = libraryItem.getMediaDescription(progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
@@ -975,6 +983,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}") progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else { } else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id } progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
// to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
itemInProgress.episode.localEpisodeId = localEpisode?.id
}
} }
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx) mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
} else { } else {
@@ -982,6 +998,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id) progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
} else { } else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id } progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
(itemInProgress.libraryItemWrapper as LibraryItem).localLibraryItemId = localLibraryItem?.id // To show downloaded icon
} }
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx) mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx)
} }
@@ -1032,7 +1051,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Shake sensor used for sleep timer // Shake sensor used for sleep timer
fun registerSensor() { fun registerSensor() {
if (isShakeSensorRegistered) { if (isShakeSensorRegistered) {
Log.w(tag, "Shake sensor already registered") Log.i(tag, "Shake sensor already registered")
return return
} }
shakeSensorUnregisterTask?.cancel() shakeSensorUnregisterTask?.cancel()
@@ -1052,7 +1071,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Unregister shake sensor after wake up expiration // Unregister shake sensor after wake up expiration
shakeSensorUnregisterTask?.cancel() shakeSensorUnregisterTask?.cancel()
shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) { shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post {
Log.d(tag, "wake time expired: Unregistering shake sensor") Log.d(tag, "wake time expired: Unregistering shake sensor")
mSensorManager!!.unregisterListener(mShakeDetector) mSensorManager!!.unregisterListener(mShakeDetector)
isShakeSensorRegistered = false isShakeSensorRegistered = false
@@ -4,6 +4,7 @@ import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
import android.hardware.SensorManager import android.hardware.SensorManager
import com.audiobookshelf.app.device.DeviceManager
import kotlin.math.sqrt import kotlin.math.sqrt
class ShakeDetector : SensorEventListener { class ShakeDetector : SensorEventListener {
@@ -34,7 +35,8 @@ class ShakeDetector : SensorEventListener {
val gZ = z / SensorManager.GRAVITY_EARTH val gZ = z / SensorManager.GRAVITY_EARTH
// gForce will be close to 1 when there is no movement. // gForce will be close to 1 when there is no movement.
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ) val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
if (gForce > SHAKE_THRESHOLD_GRAVITY) { val shakeThreshold = DeviceManager.deviceData.deviceSettings?.getShakeThresholdGravity() ?: 1.6f
if (gForce > shakeThreshold) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// ignore shake events too close to each other (500ms) // ignore shake events too close to each other (500ms)
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) { if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
@@ -59,7 +61,7 @@ class ShakeDetector : SensorEventListener {
* from the Google Play Store and run it to see how * from the Google Play Store and run it to see how
* many G's it takes to register a shake * many G's it takes to register a shake
*/ */
private const val SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f // var SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
private const val SHAKE_SLOP_TIME_MS = 500 private const val SHAKE_SLOP_TIME_MS = 500
private const val SHAKE_COUNT_RESET_TIME_MS = 3000 private const val SHAKE_COUNT_RESET_TIME_MS = 3000
} }
@@ -6,7 +6,9 @@ import android.util.Log
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
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.player.CastManager import com.audiobookshelf.app.player.CastManager
import com.audiobookshelf.app.player.PlayerListener
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
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
@@ -21,7 +23,7 @@ import org.json.JSONObject
@CapacitorPlugin(name = "AbsAudioPlayer") @CapacitorPlugin(name = "AbsAudioPlayer")
class AbsAudioPlayer : Plugin() { class AbsAudioPlayer : Plugin() {
private val tag = "AbsAudioPlayer" private val tag = "AbsAudioPlayer"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private lateinit var mainActivity: MainActivity private lateinit var mainActivity: MainActivity
private lateinit var apiHandler:ApiHandler private lateinit var apiHandler:ApiHandler
@@ -88,7 +90,13 @@ class AbsAudioPlayer : Plugin() {
override fun onNetworkMeteredChanged(isUnmetered:Boolean) { override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
emit("onNetworkMeteredChanged", isUnmetered) emit("onNetworkMeteredChanged", isUnmetered)
} }
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
}
}) })
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
} }
mainActivity.pluginCallback = foregroundServiceReady mainActivity.pluginCallback = foregroundServiceReady
} }
@@ -160,6 +168,8 @@ class AbsAudioPlayer : Plugin() {
val episodeId = call.getString("episodeId", "").toString() val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true val playWhenReady = call.getBoolean("playWhenReady") == true
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
val startTimeOverride = call.getDouble("startTime")
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride")
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")
@@ -181,10 +191,16 @@ 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)}")
val playbackSession = it.getPlaybackSession(episode) val playbackSession = it.getPlaybackSession(episode)
if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
playbackSession.currentTime = startTimeOverride
}
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop { playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped") Log.d(tag, "Media progress syncer was already syncing - stopped")
PlayerListener.lazyIsPlaying = false
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer( playerNotificationService.preparePlayer(
playbackSession, playbackSession,
@@ -194,6 +210,7 @@ class AbsAudioPlayer : Plugin() {
} }
} }
} else { } else {
playerNotificationService.mediaProgressSyncer.reset()
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate) playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
} }
} }
@@ -201,32 +218,29 @@ class AbsAudioPlayer : Plugin() {
} }
} else { // Play library item from server } else { // Play library item from server
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false) val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
Handler(Looper.getMainLooper()).post {
playerNotificationService.mediaProgressSyncer.stop {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) { if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}")) call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else { } else {
if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
it.currentTime = startTimeOverride
}
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
PlayerListener.lazyIsPlaying = false
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
} }
}
} else {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it))) call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
} }
} }
} }
} }
}
}
@PluginMethod @PluginMethod
fun getCurrentTime(call: PluginCall) { fun getCurrentTime(call: PluginCall) {
@@ -267,6 +281,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun seek(call: PluginCall) { fun seek(call: PluginCall) {
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
Log.d(tag, "seek action to $time")
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()
@@ -1,8 +1,10 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.plugins
import android.util.Log import android.util.Log
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
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
@@ -16,7 +18,7 @@ import kotlinx.coroutines.launch
@CapacitorPlugin(name = "AbsDatabase") @CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() { class AbsDatabase : Plugin() {
val tag = "AbsDatabase" val tag = "AbsDatabase"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler lateinit var apiHandler: ApiHandler
@@ -188,6 +190,24 @@ class AbsDatabase : Plugin() {
} }
} }
@PluginMethod
fun getLocalMediaProgressForServerItem(call:PluginCall) {
val libraryItemId = call.getString("libraryItemId", "").toString()
var episodeId:String? = call.getString("episodeId", "").toString()
if (episodeId == "") episodeId = null
GlobalScope.launch(Dispatchers.IO) {
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
val localMediaProgress = allLocalMediaProgress.find { libraryItemId == it.libraryItemId && (episodeId == null || it.episodeId == episodeId) }
if (localMediaProgress == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
}
}
@PluginMethod @PluginMethod
fun removeLocalMediaProgress(call:PluginCall) { fun removeLocalMediaProgress(call:PluginCall) {
val localMediaProgressId = call.getString("localMediaProgressId", "").toString() val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
@@ -256,6 +276,8 @@ class AbsDatabase : Plugin() {
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId") Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
call.resolve() call.resolve()
} else { } else {
MediaEventManager.syncEvent(mediaProgress, "Received from webhook event")
localMediaProgress.updateFromServerMediaProgress(mediaProgress) localMediaProgress.updateFromServerMediaProgress(mediaProgress)
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
@@ -286,7 +308,7 @@ class AbsDatabase : Plugin() {
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} }
var duration = 0.0 val duration: Double
var podcastEpisode: PodcastEpisode? = null var podcastEpisode: PodcastEpisode? = null
if (!localEpisodeId.isNullOrEmpty()) { if (!localEpisodeId.isNullOrEmpty()) {
val podcast = localLibraryItem.media as Podcast val podcast = localLibraryItem.media as Podcast
@@ -406,4 +428,19 @@ class AbsDatabase : Plugin() {
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData))) call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
} }
} }
@PluginMethod
fun getMediaItemHistory(call:PluginCall) { // Returns device data
Log.d(tag, "getMediaItemHistory ${call.data}")
val mediaId = call.getString("mediaId") ?: ""
GlobalScope.launch(Dispatchers.IO) {
val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId)
if (mediaItemHistory == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
}
}
}
} }
@@ -2,20 +2,17 @@ package com.audiobookshelf.app.plugins
import android.app.DownloadManager import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment 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
import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore import com.audiobookshelf.app.managers.DownloadItemManager
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.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -23,108 +20,37 @@ import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File import java.io.File
@CapacitorPlugin(name = "AbsDownloader") @CapacitorPlugin(name = "AbsDownloader")
class AbsDownloader : Plugin() { class AbsDownloader : Plugin() {
private val tag = "AbsDownloader" private val tag = "AbsDownloader"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity lateinit var mainActivity: MainActivity
lateinit var downloadManager: DownloadManager lateinit var downloadManager: DownloadManager
lateinit var apiHandler: ApiHandler lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner lateinit var folderScanner: FolderScanner
lateinit var downloadItemManager: DownloadItemManager
data class DownloadItemPart( private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
val id: String, override fun onDownloadItem(downloadItem:DownloadItem) {
val filename: String, notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
val finalDestinationPath:String,
val itemTitle: String,
val serverPath: String,
val localFolderName: String,
val localFolderUrl: String,
val localFolderId: String,
val audioTrack: AudioTrack?,
val episode:PodcastEpisode?,
var completed:Boolean,
var moved:Boolean,
var failed:Boolean,
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
var downloadId: 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
)
} }
override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) {
notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart)))
} }
override fun onDownloadItemComplete(jsobj:JSObject) {
@JsonIgnore notifyListeners("onItemDownloadComplete", jsobj)
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
} }
} })
data class DownloadItem(
val id: String,
val libraryItemId:String,
val episodeId:String?,
val userMediaProgress:MediaProgress?,
val serverConnectionConfigId:String,
val serverAddress:String,
val serverUserId:String,
val mediaType: String,
val itemFolderPath:String,
val localFolder: LocalFolder,
val itemTitle: String,
val media:MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
)
var downloadQueue: MutableList<DownloadItem> = mutableListOf()
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
folderScanner = FolderScanner(mainActivity) folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity) apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}") Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
} }
@@ -137,7 +63,7 @@ class AbsDownloader : Plugin() {
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadQueue.find { it.id == downloadId } != null) { if (downloadItemManager.downloadItemQueue.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\"}"))
} }
@@ -205,74 +131,84 @@ class AbsDownloader : Plugin() {
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
if (libraryItem.mediaType == "book") { if (libraryItem.mediaType == "book") {
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
val 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")
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle val itemSubfolder = "$bookAuthor/$bookTitle"
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()) val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, 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 ->
val fileSize = audioTrack.metadata?.size ?: 0
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}" val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
val 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")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete() finalDestinationFile.delete()
} }
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,audioTrack,null) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
val dlRequest = downloadItemPart.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
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) {
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
val serverPath = "/api/items/${libraryItem.id}/cover" val serverPath = "/api/items/${libraryItem.id}/cover"
val destinationFilename = "cover.jpg" val destinationFilename = "cover-${libraryItem.id}.jpg"
val destinationFile = File("$tempFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}") Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete() finalDestinationFile.delete()
} }
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,null,null) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
val dlRequest = downloadItemPart.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
} }
downloadQueue.add(downloadItem) downloadItemManager.addDownloadItem(downloadItem)
startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem)
} }
} else { } else {
// Podcast episode download // Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val audioTrack = episode?.audioTrack val audioTrack = episode?.audioTrack
val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download") Log.d(tag, "Starting podcast episode download")
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
val downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItemId = "${libraryItem.id}-${episode?.id}"
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()) val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, 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("$tempFolderPath/$destinationFilename") var destinationFile = File("$tempFolderPath/$destinationFilename")
var finalDestinationFile = File("$itemFolderPath/$destinationFilename") var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
@@ -281,14 +217,13 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode) var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
serverPath = "/api/items/${libraryItem.id}/cover" serverPath = "/api/items/${libraryItem.id}/cover"
destinationFilename = "cover.jpg" destinationFilename = "cover.jpg"
@@ -298,134 +233,12 @@ class AbsDownloader : Plugin() {
if (finalDestinationFile.exists()) { 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 {
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null) downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
dlRequest = downloadItemPart.getDownloadRequest()
downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
} }
} }
downloadQueue.add(downloadItem) downloadItemManager.addDownloadItem(downloadItem)
startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
}
private fun startWatchingDownloads(downloadItem: DownloadItem) {
GlobalScope.launch(Dispatchers.IO) {
while (downloadItem.downloadItemParts.find { !it.moved && !it.failed } != null) { // While some item is not completed
val numPartsBefore = downloadItem.downloadItemParts.size
checkDownloads(downloadItem)
// Keep database updated as item parts finish downloading
if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) {
Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}")
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
notifyListeners("onItemDownloadUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
delay(500)
}
// Remove download notifications
downloadItem.downloadItemParts.forEach { downloadItemPart ->
downloadItemPart.downloadId?.let {
downloadManager.remove(it)
}
}
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
downloadQueue.remove(downloadItem)
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
val jsobj = JSObject()
jsobj.put("libraryItemId", downloadItem.id)
jsobj.put("localFolderId", downloadItem.localFolder.id)
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
}
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
notifyListeners("onItemDownloadComplete", jsobj)
}
}
private fun checkDownloads(downloadItem: DownloadItem) {
val itemParts = downloadItem.downloadItemParts.map { it }
for (downloadItemPart in itemParts) {
if (downloadItemPart.downloadId != null) {
val dlid = downloadItemPart.downloadId!!
val query = DownloadManager.Query().setFilterById(dlid)
downloadManager.query(query).use {
if (it.moveToFirst()) {
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
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")
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
// Once file download is complete move the file to the final destination
if (!downloadItemPart.completed) {
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))
if (localFolderFile == null) {
Log.e(tag, "Local Folder File from uri is null")
} else {
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) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItem.downloadItemParts.remove(downloadItemPart)
} else {
//update progress
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
downloadItemPart.progress = percentProgress
}
} else {
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
downloadItem.downloadItemParts.remove(downloadItemPart)
}
}
}
} }
} }
} }
@@ -28,7 +28,7 @@ import kotlinx.coroutines.launch
class AbsFileSystem : Plugin() { class AbsFileSystem : Plugin() {
private val TAG = "AbsFileSystem" private val TAG = "AbsFileSystem"
private val tag = "AbsFileSystem" private val tag = "AbsFileSystem"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity lateinit var mainActivity: MainActivity
@@ -85,7 +85,14 @@ class AbsFileSystem : Plugin() {
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
} }
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) { override fun onStorageAccessDenied(
requestCode: Int,
folder: DocumentFile?,
storageType: StorageType,
storageId: String
) {
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
val jsobj = JSObject() val jsobj = JSObject()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) { if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
@@ -99,7 +106,7 @@ class AbsFileSystem : Plugin() {
call.resolve(jsobj) call.resolve(jsobj)
} }
} }
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, storageType) } builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
builder.show() builder.show()
} else { } else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
@@ -131,7 +138,7 @@ class AbsFileSystem : Plugin() {
@PluginMethod @PluginMethod
fun checkStoragePermission(call: PluginCall) { fun checkStoragePermission(call: PluginCall) {
var res: Boolean val res: Boolean
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
res = SimpleStorage.hasStoragePermission(context) res = SimpleStorage.hasStoragePermission(context)
Log.d(TAG, "checkStoragePermission: Check Storage Access $res") Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
@@ -140,33 +147,33 @@ class AbsFileSystem : Plugin() {
res = true res = true
} }
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("value", res) jsobj.put("value", res)
call.resolve(jsobj) call.resolve(jsobj)
} }
@PluginMethod @PluginMethod
fun checkFolderPermissions(call: PluginCall) { fun checkFolderPermissions(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString() val folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl") Log.d(TAG, "Check Folder Permissions for $folderUrl")
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true) val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("value", hasAccess) jsobj.put("value", hasAccess)
call.resolve(jsobj) call.resolve(jsobj)
} }
@PluginMethod @PluginMethod
fun scanFolder(call: PluginCall) { fun scanFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString() val folderId = call.data.getString("folderId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe") val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe") Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId) val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.let { folder?.let {
var folderScanner = FolderScanner(context) val folderScanner = FolderScanner(context)
var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe) val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
if (folderScanResult == null) { if (folderScanResult == null) {
Log.d(TAG, "NO Scan DATA") Log.d(TAG, "NO Scan DATA")
return call.resolve(JSObject()) return call.resolve(JSObject())
@@ -179,28 +186,28 @@ class AbsFileSystem : Plugin() {
@PluginMethod @PluginMethod
fun removeFolder(call: PluginCall) { fun removeFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString() val folderId = call.data.getString("folderId", "").toString()
DeviceManager.dbManager.removeLocalFolder(folderId) DeviceManager.dbManager.removeLocalFolder(folderId)
call.resolve() call.resolve()
} }
@PluginMethod @PluginMethod
fun removeLocalLibraryItem(call: PluginCall) { fun removeLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
call.resolve() call.resolve()
} }
@PluginMethod @PluginMethod
fun scanLocalLibraryItem(call: PluginCall) { fun scanLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe") val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe") Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
localLibraryItem?.let { localLibraryItem?.let {
var folderScanner = FolderScanner(context) val folderScanner = FolderScanner(context)
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe) val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
if (scanResult == null) { if (scanResult == null) {
Log.d(TAG, "NO Scan DATA") Log.d(TAG, "NO Scan DATA")
call.resolve(JSObject()) call.resolve(JSObject())
@@ -214,13 +221,13 @@ class AbsFileSystem : Plugin() {
@PluginMethod @PluginMethod
fun deleteItem(call: PluginCall) { fun deleteItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString() val localLibraryItemId = call.data.getString("id", "").toString()
var absolutePath = call.data.getString("absolutePath", "").toString() val absolutePath = call.data.getString("absolutePath", "").toString()
var contentUrl = call.data.getString("contentUrl", "").toString() val contentUrl = call.data.getString("contentUrl", "").toString()
Log.d(tag, "deleteItem $absolutePath | $contentUrl") Log.d(tag, "deleteItem $absolutePath | $contentUrl")
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true val success = docfile?.delete() == true
if (success) { if (success) {
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
} }
@@ -229,19 +236,19 @@ class AbsFileSystem : Plugin() {
@PluginMethod @PluginMethod
fun deleteTrackFromItem(call: PluginCall) { fun deleteTrackFromItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString() val localLibraryItemId = call.data.getString("id", "").toString()
var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString() val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
var contentUrl = call.data.getString("trackContentUrl", "").toString() val contentUrl = call.data.getString("trackContentUrl", "").toString()
Log.d(tag, "deleteTrackFromItem $contentUrl") Log.d(tag, "deleteTrackFromItem $contentUrl")
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) { if (localLibraryItem == null) {
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId") Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
return call.resolve(JSObject("{\"success\":false}")) return call.resolve(JSObject("{\"success\":false}"))
} }
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true val success = docfile?.delete() == true
if (success) { if (success) {
localLibraryItem.media.removeAudioTrack(trackLocalFileId) localLibraryItem.media.removeAudioTrack(trackLocalFileId)
localLibraryItem.removeLocalFile(trackLocalFileId) localLibraryItem.removeLocalFile(trackLocalFileId)
@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
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.media.MediaEventManager
import com.audiobookshelf.app.player.MediaProgressSyncData import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
@@ -31,8 +32,8 @@ class ApiHandler(var ctx:Context) {
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>) data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>) data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>, val serverProgressUpdates:List<MediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int) data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int, var serverProgressUpdates:List<MediaProgress>)
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) { fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress val address = config?.address ?: DeviceManager.serverAddress
@@ -226,26 +227,26 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) { fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(syncData)) val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload, null) { postRequest("/api/session/$sessionId/sync", payload, null) {
if (!it.getString("error").isNullOrEmpty()) { if (!it.getString("error").isNullOrEmpty()) {
cb(false) cb(false, it.getString("error"))
} else { } else {
cb(true) cb(true, null)
} }
} }
} }
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) { fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession)) val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload, null) { postRequest("/api/session/local", payload, null) {
if (!it.getString("error").isNullOrEmpty()) { if (!it.getString("error").isNullOrEmpty()) {
cb(false) cb(false, it.getString("error"))
} else { } else {
cb(true) cb(true, null)
} }
} }
} }
@@ -253,7 +254,7 @@ class ApiHandler(var ctx:Context) {
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) { fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
if (!isOnline()) { if (!isOnline()) {
Log.d(tag, "Error not online") Log.d(tag, "Error not online")
cb(LocalMediaProgressSyncResultsPayload(0,0,0)) cb(LocalMediaProgressSyncResultsPayload(0,0,0, mutableListOf()))
return return
} }
@@ -262,7 +263,7 @@ class ApiHandler(var ctx:Context) {
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
} }
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0) val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0, mutableListOf())
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")
@@ -278,14 +279,21 @@ class ApiHandler(var ctx:Context) {
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString()) val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
localSyncResultsPayload.serverProgressUpdates = progressSyncResponsePayload.serverProgressUpdates
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload") Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) { if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
// Update all local media progress // Update all local media progress
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress -> progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
MediaEventManager.syncEvent(localMediaProgress, "Local progress updated. Received from server sync local API request")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
} }
} }
progressSyncResponsePayload.serverProgressUpdates.forEach { localMediaProgress ->
MediaEventManager.syncEvent(localMediaProgress, "Server progress updated. Received from server sync local API request")
}
} }
cb(localSyncResultsPayload) cb(localSyncResultsPayload)
+2 -2
View File
@@ -9,7 +9,7 @@
<item name="colorAccent">@color/colorAccent</item> <item name="colorAccent">@color/colorAccent</item>
</style> </style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar"> <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@null</item>
@@ -18,7 +18,7 @@
</style> </style>
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar"> <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<!-- <item name="android:background">@drawable/screen</item>--> <!-- <item name="android:background">@drawable/screen</item>-->
</style> </style>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget"> <style name="Widget.Android.AppWidget.Container" parent="android:Widget">
+4 -3
View File
@@ -1,15 +1,15 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.5.30' ext.kotlin_version = '1.7.20'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.google.gms:google-services:4.3.10' classpath 'com.google.gms:google-services:4.3.13'
classpath 'com.android.tools.build:gradle:7.2.2' classpath 'com.android.tools.build:gradle:7.4.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
@@ -29,3 +29,4 @@ allprojects {
task clean(type: Delete) { task clean(type: Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }
+3 -3
View File
@@ -14,8 +14,8 @@ project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-network' include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android') project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
include ':capacitor-status-bar' include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capacitor-storage'
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
+1 -1
View File
@@ -1,6 +1,6 @@
#Sun Apr 17 13:28:55 CDT 2022 #Sun Apr 17 13:28:55 CDT 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+12 -10
View File
@@ -1,16 +1,16 @@
ext { ext {
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 31 compileSdkVersion = 33
targetSdkVersion = 31 targetSdkVersion = 32
androidxActivityVersion = '1.2.0' androidxActivityVersion = '1.4.0'
androidxAppCompatVersion = '1.4.1' androidxAppCompatVersion = '1.4.2'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.6.0' androidxCoreVersion = '1.8.0'
androidPlayCore = '1.9.0' androidPlayCore = '1.9.0'
androidxFragmentVersion = '1.3.0' androidxFragmentVersion = '1.4.1'
junitVersion = '4.13.1' junitVersion = '4.13.2'
androidxJunitVersion = '1.1.2' androidxJunitVersion = '1.1.3'
androidxEspressoCoreVersion = '3.3.0' androidxEspressoCoreVersion = '3.4.0'
cordovaAndroidVersion = '10.1.1' cordovaAndroidVersion = '10.1.1'
androidx_car_version = '1.0.0-alpha7' androidx_car_version = '1.0.0-alpha7'
androidx_core_ktx_version = '1.7.0' androidx_core_ktx_version = '1.7.0'
@@ -27,11 +27,13 @@ ext {
gradle_version = '3.1.4' gradle_version = '3.1.4'
gson_version = '2.8.5' gson_version = '2.8.5'
junit_version = '4.13' junit_version = '4.13'
kotlin_version = '1.5.30' kotlin_version = '1.7.20'
kotlin_coroutines_version = '1.1.0' kotlin_coroutines_version = '1.1.0'
multidex_version = '1.0.3' multidex_version = '1.0.3'
play_services_auth_version = '18.1.0' play_services_auth_version = '18.1.0'
recycler_view_version = '1.1.0' recycler_view_version = '1.1.0'
robolectric_version = '4.2' robolectric_version = '4.2'
test_runner_version = '1.1.0' test_runner_version = '1.1.0'
coreSplashScreenVersion = '1.0.0-rc01'
androidxWebkitVersion = '1.4.0'
} }
+2 -2
View File
@@ -2,14 +2,14 @@
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-Regular.ttf) format('truetype');
} }
@font-face { @font-face {
font-family: 'Material Icons Outlined'; font-family: 'Material Icons Outlined';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2'); src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
} }
.material-icons { .material-icons {
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative z-20">
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-10 flex items-center px-2"> <div id="appbar" class="absolute top-0 left-0 w-full h-full flex items-center px-2">
<nuxt-link v-show="!showBack" to="/" class="mr-3"> <nuxt-link v-show="!showBack" to="/" class="mr-3">
<img src="/Logo.png" class="h-10 w-10" /> <img src="/Logo.png" class="h-10 w-10" />
</nuxt-link> </nuxt-link>
+135 -68
View File
@@ -1,19 +1,21 @@
<template> <template>
<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="playbackSession" id="streamContainer" class="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 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)" />
<div class="top-4 left-4 absolute cursor-pointer"> <div class="top-4 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span> <span class="material-icons text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">expand_more</span>
</div> </div>
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer"> <div v-show="showCastBtn" class="top-6 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 ? (coverBgIsLight ? 'text-successDark' : 'text-success') : coverBgIsLight ? 'text-black' : ''" @click="castClick">cast</span>
</div> </div>
<div class="top-6 right-4 absolute cursor-pointer"> <div class="top-6 right-4 absolute cursor-pointer">
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span> <span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
</div> </div>
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p> <p class="top-4 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 && useTotalTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
<div class="flex"> <div class="flex">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -29,8 +31,8 @@
</div> </div>
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer"> <div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full"> <div class="w-full h-full flex justify-center">
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
</div> </div>
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30"> <div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
@@ -39,12 +41,12 @@
</div> </div>
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor"> <div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
<p class="title-text font-book truncate">{{ title }}</p> <p class="title-text truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p> <p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
</div> </div>
<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 id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @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 bottom-4 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 && isServerItem && networkConnected" 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 && isServerItem && networkConnected" 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 -->
@@ -61,13 +63,16 @@
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span> <span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
</div> </div>
</div> </div>
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
<div id="playerControls" class="absolute right-0 bottom-0 py-2"> <div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span> <span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span> <div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" /> <widgets-spinner-icon v-else class="h-8 w-8" />
</div> </div>
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
@@ -76,6 +81,13 @@
</div> </div>
<div id="playerTrack" class="absolute left-0 w-full px-3"> <div id="playerTrack" class="absolute left-0 w-full px-3">
<div class="flex pb-0.5">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
<div class="flex-grow" />
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
<div class="flex-grow" />
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div>
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack"> <div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" /> <div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
@@ -83,41 +95,18 @@
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" /> <div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi || !showFullscreen }" /> <div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi || !showFullscreen }" />
</div> </div>
<div id="timestamp-row" class="flex pt-0.5">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
<div class="flex-grow" />
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
<div class="flex-grow" />
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div>
</div> </div>
</div> </div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction"> <modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
<template v-slot:chapter_track="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
<template v-slot:lock="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
</modals-dialog>
</div> </div>
</template> </template>
<script> <script>
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor' import { AbsAudioPlayer } from '@/plugins/capacitor'
import { FastAverageColor } from 'fast-average-color'
export default { export default {
props: { props: {
@@ -132,6 +121,7 @@ export default {
data() { data() {
return { return {
windowHeight: 0, windowHeight: 0,
windowWidth: 0,
playbackSession: null, playbackSession: null,
showChapterModal: false, showChapterModal: false,
showFullscreen: false, showFullscreen: false,
@@ -157,23 +147,45 @@ export default {
touchStartTime: 0, touchStartTime: 0,
touchEndY: 0, touchEndY: 0,
useChapterTrack: false, useChapterTrack: false,
useTotalTrack: true,
lockUi: false, lockUi: false,
isLoading: false, isLoading: false,
touchTrackStart: false, touchTrackStart: false,
dragPercent: 0, dragPercent: 0,
syncStatus: 0, syncStatus: 0,
showMoreMenuDialog: false showMoreMenuDialog: false,
coverRgb: 'rgb(55, 56, 56)',
coverBgIsLight: false
} }
}, },
watch: { watch: {
showFullscreen(val) { showFullscreen(val) {
this.updateScreenSize() this.updateScreenSize()
this.$store.commit('setPlayerFullscreen', !!val) this.$store.commit('setPlayerFullscreen', !!val)
},
bookCoverAspectRatio() {
this.updateScreenSize()
} }
}, },
computed: { computed: {
menuItems() { menuItems() {
var items = [ const items = []
// TODO: Implement on iOS
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
items.push({
text: 'History',
value: 'history',
icon: 'history'
})
}
items.push(
...[
{
text: 'Total Track',
value: 'total_track',
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
},
{ {
text: 'Chapter Track', text: 'Chapter Track',
value: 'chapter_track', value: 'chapter_track',
@@ -190,6 +202,7 @@ export default {
icon: 'close' icon: 'close'
} }
] ]
)
return items return items
}, },
@@ -213,11 +226,26 @@ export default {
return 46 / this.bookCoverAspectRatio return 46 / this.bookCoverAspectRatio
}, },
fullscreenBookCoverWidth() { fullscreenBookCoverWidth() {
var heightScale = (this.windowHeight - 200) / 651 if (this.windowWidth < this.windowHeight) {
// Portrait
let sideSpace = 20
if (this.bookCoverAspectRatio === 1.6) sideSpace += (this.windowWidth - sideSpace) * 0.375
const availableHeight = this.windowHeight - 400
let width = this.windowWidth - sideSpace
const totalHeight = width * this.bookCoverAspectRatio
if (totalHeight > availableHeight) {
width = availableHeight / this.bookCoverAspectRatio
}
return width
} else {
// Landscape
const heightScale = (this.windowHeight - 200) / 651
if (this.bookCoverAspectRatio === 1) { if (this.bookCoverAspectRatio === 1) {
return 260 * heightScale return 260 * heightScale
} }
return 200 * heightScale return 190 * heightScale
}
}, },
showCastBtn() { showCastBtn() {
return this.$store.state.isCastAvailable return this.$store.state.isCastAvailable
@@ -328,9 +356,33 @@ export default {
}, },
networkConnected() { networkConnected() {
return this.$store.state.networkConnected return this.$store.state.networkConnected
},
mediaId() {
if (this.isPodcast || !this.playbackSession) return null
if (this.playbackSession.libraryItemId) {
return this.playbackSession.episodeId ? `${this.playbackSession.libraryItemId}-${this.playbackSession.episodeId}` : this.playbackSession.libraryItemId
}
const localLibraryItem = this.playbackSession.localLibraryItem
if (!localLibraryItem) return null
return this.playbackSession.localEpisodeId ? `${localLibraryItem.id}-${this.playbackSession.localEpisodeId}` : localLibraryItem.id
} }
}, },
methods: { methods: {
async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return
const fac = new FastAverageColor()
fac
.getColorAsync(fullCoverUrl)
.then((color) => {
this.coverRgb = color.rgba
this.coverBgIsLight = color.isLight
})
.catch((e) => {
console.log(e)
})
},
clickTitleAndAuthor() { clickTitleAndAuthor() {
if (!this.showFullscreen) return if (!this.showFullscreen) return
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
@@ -340,17 +392,17 @@ export default {
} }
}, },
async touchstartTrack(e) { async touchstartTrack(e) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true this.touchTrackStart = true
}, },
async selectChapter(chapter) { async selectChapter(chapter) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.seek(chapter.start) this.seek(chapter.start)
this.showChapterModal = false this.showChapterModal = false
}, },
async castClick() { async castClick() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isLocalPlayMethod) { if (this.isLocalPlayMethod) {
this.$eventBus.$emit('cast-local-item') this.$eventBus.$emit('cast-local-item')
return return
@@ -370,13 +422,13 @@ export default {
this.forceCloseDropdownMenu() this.forceCloseDropdownMenu()
}, },
async jumpNextChapter() { async jumpNextChapter() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isLoading) return if (this.isLoading) return
if (!this.nextChapter) return if (!this.nextChapter) return
this.seek(this.nextChapter.start) this.seek(this.nextChapter.start)
}, },
async jumpChapterStart() { async jumpChapterStart() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isLoading) return if (this.isLoading) return
if (!this.currentChapter) { if (!this.currentChapter) {
return this.restart() return this.restart()
@@ -397,7 +449,7 @@ export default {
this.$emit('showSleepTimer') this.$emit('showSleepTimer')
}, },
async setPlaybackSpeed(speed) { async setPlaybackSpeed(speed) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`) console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed this.currentPlaybackRate = speed
AbsAudioPlayer.setPlaybackSpeed({ value: speed }) AbsAudioPlayer.setPlaybackSpeed({ value: speed })
@@ -406,12 +458,12 @@ export default {
this.seek(0) this.seek(0)
}, },
async jumpBackwards() { async jumpBackwards() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime }) AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
}, },
async jumpForward() { async jumpForward() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime }) AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
}, },
@@ -551,7 +603,7 @@ export default {
this.seek(time) this.seek(time)
}, },
async playPauseClick() { async playPauseClick() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isLoading) return if (this.isLoading) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
@@ -655,19 +707,31 @@ export default {
} }
}, },
async clickMenuAction(action) { async clickMenuAction(action) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.showMoreMenuDialog = false this.showMoreMenuDialog = false
this.$nextTick(() => { this.$nextTick(() => {
if (action === 'lock') { if (action === 'history') {
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
this.showFullscreen = false
} else if (action === 'lock') {
this.lockUi = !this.lockUi this.lockUi = !this.lockUi
this.$localStore.setPlayerLock(this.lockUi) this.$localStore.setPlayerLock(this.lockUi)
} else if (action === 'chapter_track') { } else if (action === 'chapter_track') {
this.useChapterTrack = !this.useChapterTrack this.useChapterTrack = !this.useChapterTrack
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
this.updateTimestamp() this.updateTimestamp()
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
this.$localStore.setUseChapterTrack(this.useChapterTrack) this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'total_track') {
this.useTotalTrack = !this.useTotalTrack
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
this.updateTimestamp()
this.updateTrack()
this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack)
} else if (action === 'close') { } else if (action === 'close') {
this.closePlayback() this.closePlayback()
} }
@@ -775,9 +839,14 @@ export default {
}, },
updateScreenSize() { updateScreenSize() {
this.windowHeight = window.innerHeight this.windowHeight = window.innerHeight
this.windowWidth = window.innerWidth
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px') document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px') document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 24 + coverImageWidthCollapsed + 'px')
}, },
minimizePlayerEvt() { minimizePlayerEvt() {
this.showFullscreen = false this.showFullscreen = false
@@ -843,10 +912,9 @@ export default {
--cover-image-height: 0px; --cover-image-height: 0px;
--cover-image-width-collapsed: 46px; --cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 46px; --cover-image-height-collapsed: 46px;
--title-author-left-offset-collapsed: 70px;
} }
.bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066;
}
.playerContainer { .playerContainer {
height: 100px; height: 100px;
} }
@@ -856,19 +924,18 @@ export default {
#playerContent { #playerContent {
box-shadow: 0px -8px 8px #11111155; box-shadow: 0px -8px 8px #11111155;
} }
.fullscreen #playerContent {
box-shadow: none;
}
#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);
transition-property: margin; transition-property: margin;
bottom: 10px; bottom: 20px;
} }
.fullscreen #playerTrack { .fullscreen #playerTrack {
bottom: 32px; top: 20px;
} bottom: unset;
.ios-player #timestamp-row {
padding-left: 16px;
padding-right: 16px;
} }
.cover-wrapper { .cover-wrapper {
@@ -894,7 +961,7 @@ export default {
width: 40%; width: 40%;
bottom: 56px; bottom: 56px;
left: 70px; left: var(--title-author-left-offset-collapsed);
text-align: left; text-align: left;
} }
.title-author-texts .title-text { .title-author-texts .title-text {
@@ -919,10 +986,10 @@ export default {
pointer-events: auto; pointer-events: auto;
} }
.fullscreen .title-author-texts .title-text { .fullscreen .title-author-texts .title-text {
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.2rem); font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.5rem);
} }
.fullscreen .title-author-texts .author-text { .fullscreen .title-author-texts .author-text {
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem); font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1.1rem);
} }
#playerControls { #playerControls {
@@ -969,7 +1036,7 @@ export default {
.fullscreen #playerControls { .fullscreen #playerControls {
width: 100%; width: 100%;
bottom: 105px; bottom: 94px;
} }
.fullscreen #playerControls .jump-icon { .fullscreen #playerControls .jump-icon {
margin: 0px 18px; margin: 0px 18px;
+16 -3
View File
@@ -184,6 +184,7 @@ export default {
async playLibraryItem(payload) { async playLibraryItem(payload) {
const libraryItemId = payload.libraryItemId const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId const episodeId = payload.episodeId
const startTime = payload.startTime
// When playing local library item and can also play this item from the server // 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 // then store the server library item id so it can be used if a cast is made
@@ -200,6 +201,16 @@ export default {
} }
} }
// if already playing this item then jump to start time
if (this.$store.getters['getIsMediaStreaming'](libraryItemId, episodeId)) {
console.log('Already streaming item', startTime)
if (startTime !== undefined && startTime !== null) {
// seek to start time
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
}
return
}
this.serverLibraryItemId = null this.serverLibraryItemId = null
this.serverEpisodeId = null this.serverEpisodeId = null
@@ -209,7 +220,9 @@ export default {
} }
console.log('Called playLibraryItem', libraryItemId) console.log('Called playLibraryItem', libraryItemId)
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate }) const preparePayload = { libraryItemId, episodeId, playWhenReady: true, playbackRate }
if (startTime !== undefined && startTime !== null) preparePayload.startTime = startTime
AbsAudioPlayer.prepareLibraryItem(preparePayload)
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
const errorMsg = data.error || 'Failed to play' const errorMsg = data.error || 'Failed to play'
@@ -273,7 +286,7 @@ export default {
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.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated }) this.$eventBus.$on('user-settings', this.settingsUpdated)
}, },
beforeDestroy() { beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
@@ -286,7 +299,7 @@ export default {
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.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$store.commit('user/removeSettingsListener', 'streamContainer') this.$eventBus.$off('user-settings', this.settingsUpdated)
} }
} }
</script> </script>
+1 -1
View File
@@ -137,7 +137,7 @@ export default {
this.show = false this.show = false
}, },
async logout() { async logout() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.user) { if (this.user) {
await this.$axios.$post('/logout').catch((error) => { await this.$axios.$post('/logout').catch((error) => {
console.error(error) console.error(error)
+16 -10
View File
@@ -309,7 +309,7 @@ export default {
var { clientHeight, clientWidth } = bookshelf var { clientHeight, clientWidth } = bookshelf
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 = Math.max(1, 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
@@ -371,15 +371,16 @@ export default {
return searchParams.toString() return searchParams.toString()
}, },
checkUpdateSearchParams() { checkUpdateSearchParams() {
var newSearchParams = this.buildSearchParams() const newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search let currentQueryString = window.location.search
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1) if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
if (newSearchParams === '') { if (newSearchParams === '' && !currentQueryString) {
return false return false
} }
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) { if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams const queryString = newSearchParams ? `?${newSearchParams}` : ''
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + queryString
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 this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
@@ -389,12 +390,17 @@ export default {
return false return false
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams() const wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) { if (wasUpdated) {
this.resetEntities() this.resetEntities()
} }
}, },
libraryChanged() { libraryChanged() {
if (this.currentLibraryMediaType !== 'book' && (this.page === 'series' || this.page === 'collections' || this.page === 'series-books')) {
this.$router.replace('/bookshelf')
return
}
if (this.hasFilter) { if (this.hasFilter) {
this.clearFilter() this.clearFilter()
} else { } else {
@@ -447,13 +453,13 @@ export default {
}) })
}, },
initListeners() { initListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper') const bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) { if (bookshelf) {
bookshelf.addEventListener('scroll', this.scroll) bookshelf.addEventListener('scroll', this.scroll)
} }
this.$eventBus.$on('library-changed', this.libraryChanged) this.$eventBus.$on('library-changed', this.libraryChanged)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated }) this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$socket.$on('item_updated', this.libraryItemUpdated) this.$socket.$on('item_updated', this.libraryItemUpdated)
this.$socket.$on('item_added', this.libraryItemAdded) this.$socket.$on('item_added', this.libraryItemAdded)
@@ -462,13 +468,13 @@ export default {
this.$socket.$on('items_added', this.libraryItemsAdded) this.$socket.$on('items_added', this.libraryItemsAdded)
}, },
removeListeners() { removeListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper') const bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) { if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll) bookshelf.removeEventListener('scroll', this.scroll)
} }
this.$eventBus.$off('library-changed', this.libraryChanged) this.$eventBus.$off('library-changed', this.libraryChanged)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf') this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$socket.$off('item_updated', this.libraryItemUpdated) this.$socket.$off('item_updated', this.libraryItemUpdated)
this.$socket.$off('item_added', this.libraryItemAdded) this.$socket.$off('item_added', this.libraryItemAdded)
+4 -4
View File
@@ -407,7 +407,7 @@ export default {
e.preventDefault() e.preventDefault()
this.selectBtnClick() this.selectBtnClick()
} else if (this.recentEpisode) { } else if (this.recentEpisode) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
var eventBus = this.$eventBus || this.$nuxt.$eventBus var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
eventBus.$emit('pause-item') eventBus.$emit('pause-item')
@@ -526,9 +526,9 @@ export default {
this.imageReady = true this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) { if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover const { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth const aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio) const arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit // If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) { if (arDiff > 0.15) {
+30 -6
View File
@@ -1,18 +1,18 @@
<template> <template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer py-1" @click="clickCard"> <div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer py-1" @click="clickCard">
<div class="h-full flex relative"> <div class="h-full flex relative">
<div class="w-20 h-20 relative" style="min-width: 80px; max-width: 80px"> <div class="list-card-cover relative">
<!-- When cover image does not fill --> <!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary"> <div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
</div> </div>
<div class="w-full h-full absolute top-0 left-0 bg-primary"> <div class="w-full h-full absolute top-0 left-0">
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> <img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
</div> </div>
<!-- No progress shown for collapsed series or podcasts in library --> <!-- No progress shown for collapsed series or podcasts in library -->
<div v-if="!isPodcast && !collapsedSeries" 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: 80 * userProgressPercent + 'px' }"></div> <div v-if="!isPodcast && !collapsedSeries" 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: coverWidth * userProgressPercent + 'px' }"></div>
</div> </div>
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }"> <p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
@@ -76,6 +76,9 @@ export default {
this.libraryItem = newVal this.libraryItem = newVal
} }
} }
},
bookCoverAspectRatio() {
this.setCSSProperties()
} }
}, },
computed: { computed: {
@@ -313,6 +316,9 @@ export default {
if (!this.isAlternativeBookshelfView) return 0 if (!this.isAlternativeBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier return 4.25 * this.sizeMultiplier
},
coverWidth() {
return 80 / this.bookCoverAspectRatio
} }
}, },
methods: { methods: {
@@ -433,9 +439,9 @@ export default {
this.imageReady = true this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) { if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover const { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth const aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio) const arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit // If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) { if (arDiff > 0.15) {
@@ -445,9 +451,13 @@ export default {
this.showCoverBg = false this.showCoverBg = false
} }
} }
},
setCSSProperties() {
document.documentElement.style.setProperty('--list-card-cover-width', this.coverWidth + 'px')
} }
}, },
mounted() { mounted() {
this.setCSSProperties()
if (this.bookMount) { if (this.bookMount) {
this.setEntity(this.bookMount) this.setEntity(this.bookMount)
@@ -458,3 +468,17 @@ export default {
} }
} }
</script> </script>
<style>
:root {
--list-card-cover-width: 80px;
}
.list-card-cover {
height: 80px;
max-height: 80px;
width: var(--list-card-cover-width);
min-width: var(--list-card-cover-width);
max-width: var(--list-card-cover-width);
}
</style>
-4
View File
@@ -61,10 +61,6 @@ export default {
}, },
seriesId() { seriesId() {
return this.series ? this.series.id : null return this.series ? this.series.id : null
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
return !!validCovers.length
} }
}, },
methods: { methods: {
+3 -3
View File
@@ -132,7 +132,7 @@ export default {
} }
}, },
async connectToServer(config) { async connectToServer(config) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
console.log('[ServerConnectForm] connectToServer', config.address) console.log('[ServerConnectForm] connectToServer', config.address)
this.processing = true this.processing = true
this.serverConfig = { this.serverConfig = {
@@ -160,7 +160,7 @@ export default {
}, },
async removeServerConfigClick() { async removeServerConfigClick() {
if (!this.serverConfig.id) return if (!this.serverConfig.id) return
await this.$hapticsImpactMedium() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
@@ -192,7 +192,7 @@ export default {
this.showAuth = true this.showAuth = true
}, },
async newServerConfigClick() { async newServerConfigClick() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.serverConfig = { this.serverConfig = {
address: '', address: '',
userId: '', userId: '',
+2
View File
@@ -169,6 +169,8 @@ export default {
this.showCoverBg = false this.showCoverBg = false
} }
} }
this.$emit('imageLoaded', this.fullCoverUrl)
}, },
imageError(err) { imageError(err) {
this.loading = false this.loading = false
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative overflow-hidden">
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
</a>
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
</div>
</template>
<script>
export default {
props: {
src: String,
width: {
type: Number,
default: 120
},
showOpenNewTab: Boolean,
bookCoverAspectRatio: Number,
showResolution: {
type: Boolean,
default: true
}
},
data() {
return {
imageFailed: false,
showCoverBg: false,
isHovering: false,
naturalHeight: 0,
naturalWidth: 0
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
cover() {
return this.src
},
sizeMultiplier() {
return this.width / 120
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
},
placeholderUrl() {
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.src}")`
}
},
imageLoaded() {
if (this.$refs.cover && this.src !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalHeight = naturalHeight
this.naturalWidth = naturalWidth
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
},
imageError(err) {
console.error('ImgError', err)
this.imageFailed = true
}
},
mounted() {}
}
</script>
+4 -5
View File
@@ -87,8 +87,7 @@ export default {
this.saveSettings() this.saveSettings()
}, },
saveSettings() { saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update this.$store.dispatch('user/updateUserSettings', this.settings)
this.$store.dispatch('user/updateUserSettings', this.settings) // TODO: No need to update settings on server...
}, },
async init() { async init() {
this.bookshelfListView = await this.$localStore.getBookshelfListView() this.bookshelfListView = await this.$localStore.getBookshelfListView()
@@ -105,17 +104,17 @@ export default {
}, },
async changeView() { async changeView() {
this.bookshelfListView = !this.bookshelfListView this.bookshelfListView = !this.bookshelfListView
await this.$hapticsImpactMedium() await this.$hapticsImpact()
} }
}, },
mounted() { mounted() {
this.init() this.init()
this.$eventBus.$on('bookshelf-total-entities', this.setTotalEntities) this.$eventBus.$on('bookshelf-total-entities', this.setTotalEntities)
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated }) this.$eventBus.$on('user-settings', this.settingsUpdated)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities) this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities)
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar') this.$eventBus.$off('user-settings', this.settingsUpdated)
} }
} }
</script> </script>
+3 -3
View File
@@ -95,7 +95,7 @@ export default {
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
async deleteBookmark(bm) { async deleteBookmark(bm) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Remove Bookmark', title: 'Remove Bookmark',
message: `Are you sure you want to remove bookmark?` message: `Are you sure you want to remove bookmark?`
@@ -114,7 +114,7 @@ export default {
this.show = false this.show = false
}, },
async clickBookmark(bm) { async clickBookmark(bm) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.$emit('select', bm) this.$emit('select', bm)
}, },
submitUpdateBookmark(updatedBookmark) { submitUpdateBookmark(updatedBookmark) {
@@ -159,7 +159,7 @@ export default {
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
async submitBookmark() { async submitBookmark() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.selectedBookmark) { if (this.selectedBookmark) {
var updatePayload = { var updatePayload = {
...this.selectedBookmark, ...this.selectedBookmark,
+2 -2
View File
@@ -9,10 +9,10 @@
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<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="chapter in chapters"> <template v-for="(chapter, index) in chapters">
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)"> <li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
<div class="relative flex items-center pl-3 pr-20"> <div class="relative flex items-center pl-3 pr-20">
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ chapter.title }}</p> <p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
<div class="absolute top-0 right-3 -mt-0.5"> <div class="absolute top-0 right-3 -mt-0.5">
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span> <span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
</div> </div>
+1
View File
@@ -13,6 +13,7 @@
<slot :name="item.value" :item="item" :selected="item.value === selected"> <slot :name="item.value" :item="item" :selected="item.value === selected">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)"> <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">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<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>
</li> </li>
+2 -2
View File
@@ -213,7 +213,7 @@ export default {
}, },
methods: { methods: {
async clearSelected() { async clearSelected() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.selected = 'all' this.selected = 'all'
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', 'all')) this.$nextTick(() => this.$emit('change', 'all'))
@@ -232,7 +232,7 @@ export default {
this.show = false this.show = false
return return
} }
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.selected = val this.selected = val
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', val)) this.$nextTick(() => this.$emit('change', val))
+1 -1
View File
@@ -49,7 +49,7 @@ export default {
}, },
methods: { methods: {
async clickedOption(lib) { async clickedOption(lib) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.show = false this.show = false
if (lib.id === this.currentLibraryId) return if (lib.id === this.currentLibraryId) return
await this.$store.dispatch('libraries/fetch', lib.id) await this.$store.dispatch('libraries/fetch', lib.id)
+1 -1
View File
@@ -127,7 +127,7 @@ export default {
}, },
methods: { methods: {
async clickedOption(val) { async clickedOption(val) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.selected === val) { if (this.selected === val) {
this.selectedDesc = !this.selectedDesc this.selectedDesc = !this.selectedDesc
} else { } else {
+2 -2
View File
@@ -43,7 +43,7 @@ export default {
return { return {
currentPlaybackRate: 0, currentPlaybackRate: 0,
MIN_SPEED: 0.5, MIN_SPEED: 0.5,
MAX_SPEED: 3 MAX_SPEED: 10
} }
}, },
watch: { watch: {
@@ -71,7 +71,7 @@ export default {
} }
}, },
rates() { rates() {
return [0.5, 1, 1.2, 1.5, 2] return [0.5, 1, 1.2, 1.5, 2, 3]
}, },
canIncrement() { canIncrement() {
return this.playbackRate + 0.1 <= this.MAX_SPEED return this.playbackRate + 0.1 <= this.MAX_SPEED
+100
View File
@@ -0,0 +1,100 @@
<template>
<modals-modal v-model="show" :width="200" height="100%">
<template #outer>
<div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Sleep Timer</p>
</div>
</template>
<div
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
@click="
show = false
manualTimerModal = false
"
>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div v-if="manualTimerModal" class="p-4">
<div class="flex mb-4" @click="manualTimerModal = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<div class="flex my-2 justify-between">
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="timeout in timeouts">
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
</div>
</li>
</template>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
</div>
</li>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Custom time</span>
</div>
</li>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {
manualTimerModal: null,
manualTimeoutMin: 1
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
timeouts() {
return [5, 10, 15, 30, 45, 60, 90]
}
},
methods: {
async clickedChapterOption() {
await this.$hapticsImpact()
this.show = false
this.$nextTick(() => this.$emit('change', 0))
},
async clickedOption(timeoutMin) {
await this.$hapticsImpact()
const timeout = timeoutMin * 1000 * 60
this.show = false
this.manualTimerModal = false
this.$nextTick(() => this.$emit('change', timeout))
},
async increaseManualTimeout() {
await this.$hapticsImpact()
this.manualTimeoutMin++
},
async decreaseManualTimeout() {
await this.$hapticsImpact()
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
}
},
mounted() {}
}
</script>
+9 -9
View File
@@ -40,7 +40,7 @@
</li> </li>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true"> <li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Manual sleep timer</span> <span class="font-normal block truncate text-lg text-center">Custom time</span>
</div> </div>
</li> </li>
</ul> </ul>
@@ -90,36 +90,36 @@ export default {
}, },
methods: { methods: {
async clickedChapterOption() { async clickedChapterOption() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true })) this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
}, },
async clickedOption(timeoutMin) { async clickedOption(timeoutMin) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
var timeout = timeoutMin * 1000 * 60 const timeout = timeoutMin * 1000 * 60
this.show = false this.show = false
this.manualTimerModal = false this.manualTimerModal = false
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false })) this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
}, },
async cancelSleepTimer() { async cancelSleepTimer() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.$emit('cancel') this.$emit('cancel')
this.show = false this.show = false
}, },
async increaseSleepTime() { async increaseSleepTime() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.$emit('increase') this.$emit('increase')
}, },
async decreaseSleepTime() { async decreaseSleepTime() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.$emit('decrease') this.$emit('decrease')
}, },
async increaseManualTimeout() { async increaseManualTimeout() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.manualTimeoutMin++ this.manualTimeoutMin++
}, },
async decreaseManualTimeout() { async decreaseManualTimeout() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.manualTimeoutMin > 1) this.manualTimeoutMin-- if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
} }
}, },
@@ -115,7 +115,7 @@ export default {
}) })
}, },
async clickPlaylist(playlist) { async clickPlaylist(playlist) {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (playlist.isItemIncluded) { if (playlist.isItemIncluded) {
this.removeFromPlaylist(playlist) this.removeFromPlaylist(playlist)
} else { } else {
@@ -165,7 +165,7 @@ export default {
this.showPlaylistNameInput = true this.showPlaylistNameInput = true
}, },
async submitCreatePlaylist() { async submitCreatePlaylist() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (!this.newPlaylistName || !this.selectedPlaylistItems.length) { if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {
return return
} }
+3 -3
View File
@@ -187,7 +187,7 @@ export default {
}, },
async downloadClick() { async downloadClick() {
if (this.downloadItem) return if (this.downloadItem) return
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isIos) { if (this.isIos) {
// no local folders on iOS // no local folders on iOS
this.startDownload() this.startDownload()
@@ -248,7 +248,7 @@ export default {
} }
}, },
async playClick() { async playClick() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item') this.$eventBus.$emit('pause-item')
} else { } else {
@@ -270,7 +270,7 @@ export default {
} }
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) { if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished var isFinished = !this.userIsFinished
@@ -0,0 +1,327 @@
<template>
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10">
<div v-if="episode" class="w-full px-1">
<div class="flex mb-2">
<div class="w-10 min-w-10">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)" :width="40" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
</div>
<div class="flex-grow px-2">
<div class="-mt-0.5 mb-0.5">
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-gray-200 underline">{{ podcast.metadata.title }}</nuxt-link>
</div>
<p v-if="publishedAt" class="text-xs text-gray-300">{{ $dateDistanceFromNow(publishedAt) }}</p>
</div>
</div>
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5 default-style" v-html="description" />
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div>
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click="addToPlaylist">
<span class="material-icons text-2xl">playlist_add</span>
</button>
<div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div>
<div class="flex-grow" />
<button v-if="!isLocal && isAdminOrUp" class="mx-1.5 mt-1.5" @click="deleteEpisodeFromServerClick">
<span class="material-icons text-xl">delete_forever</span>
</button>
</div>
</div>
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
<widgets-loading-spinner size="la-lg" />
</div>
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
export default {
props: {
libraryItemId: String,
episode: {
type: Object,
default: () => {}
},
localLibraryItemId: String,
localEpisode: {
type: Object,
default: () => {}
},
isLocal: Boolean
},
data() {
return {
isProcessingReadUpdate: false,
processing: false
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isIos() {
return this.$platform === 'ios'
},
mediaType() {
return 'podcast'
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
audioFile() {
return this.episode.audioFile
},
title() {
return this.episode.title || ''
},
description() {
if (this.episode.subtitle) return this.episode.subtitle
var desc = this.episode.description || ''
return desc
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)
},
isStreaming() {
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) {
// Check is streaming local version of this episode
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
},
itemProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
},
localMediaProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
else if (this.localLibraryItemId && this.localEpisode) {
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisode.id)
} else {
return null
}
},
itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
timeRemaining() {
if (this.streamIsPlaying) return 'Playing'
if (!this.itemProgressPercent) return this.$elapsedPretty(this.episode.duration)
if (this.userIsFinished) return 'Finished'
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left`
},
publishedAt() {
return this.episode.publishedAt
},
downloadItem() {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id)
},
localEpisodeId() {
return this.localEpisode ? this.localEpisode.id : null
},
podcast() {
return this.episode.podcast || {}
}
},
methods: {
async deleteEpisodeFromServerClick() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
})
if (value) {
this.processing = true
this.$axios
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
.then(() => {
this.$toast.success('Episode deleted from server')
})
.catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
console.error('Failed to delete episode', error)
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
}
},
addToPlaylist() {
this.$emit('addToPlaylist', this.episode)
},
async selectFolder() {
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
return folderObj
},
async downloadClick() {
if (this.downloadItem) return
await this.$hapticsImpact()
if (this.isIos) {
// no local folders on iOS
this.startDownload()
} else {
this.download()
}
},
async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder
if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType
})
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type
localFolder = await this.selectFolder()
} else if (foldersWithMediaType.length == 1) {
console.log('Only 1 local folder with this media type - auto select it')
localFolder = foldersWithMediaType[0]
} else {
console.log('Multiple folders with media type')
// this.showSelectLocalFolder = true
return
}
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
}
console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" to folder ${localFolder.name}?`
const { value } = await Dialog.confirm({
title: 'Confirm',
message: startDownloadMessage
})
if (value) {
this.startDownload(localFolder)
}
},
async startDownload(localFolder) {
var payload = {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
}
if (localFolder) {
payload.localFolderId = localFolder.id
}
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)
this.$toast.error(errorMsg)
}
},
async playClick() {
await this.$hapticsImpact()
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
if (this.localEpisode && this.localLibraryItemId) {
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
this.$eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: this.localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.episode.id
})
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
})
}
}
},
async toggleFinished() {
await this.$hapticsImpact()
this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, 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, this.episode.id)
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, 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}/${this.episode.id}`, 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'}`)
})
}
}
}
}
</script>
+1 -1
View File
@@ -61,7 +61,7 @@ export default {
return this.selectedItem ? this.selectedItem.text : '' return this.selectedItem ? this.selectedItem.text : ''
}, },
buttonClass() { buttonClass() {
var classes = [] const classes = []
if (this.small) classes.push('h-9') if (this.small) classes.push('h-9')
else classes.push('h-10') else classes.push('h-10')
+23 -2
View File
@@ -1,9 +1,15 @@
<template> <template>
<div class="relative"> <div class="relative">
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary" :class="inputClass" @keyup="keyup" /> <input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary" :class="inputClass" @keyup="keyup" />
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center"> <div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-lg">{{ prependIcon }}</span> <span class="material-icons text-lg">{{ prependIcon }}</span>
</div> </div>
<div v-if="clearable && input" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center" @click.stop="clear">
<span class="material-icons text-lg">close</span>
</div>
<div v-else-if="!clearable && appendIcon" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-lg">{{ appendIcon }}</span>
</div>
</div> </div>
</template> </template>
@@ -14,6 +20,7 @@ export default {
placeholder: String, placeholder: String,
type: String, type: String,
disabled: Boolean, disabled: Boolean,
readonly: Boolean,
borderless: Boolean, borderless: Boolean,
bg: { bg: {
type: String, type: String,
@@ -26,7 +33,12 @@ export default {
prependIcon: { prependIcon: {
type: String, type: String,
default: null default: null
} },
appendIcon: {
type: String,
default: null
},
clearable: Boolean
}, },
data() { data() {
return {} return {}
@@ -53,6 +65,9 @@ export default {
} }
}, },
methods: { methods: {
clear() {
this.input = ''
},
focus() { focus() {
if (this.$refs.input) { if (this.$refs.input) {
this.$refs.input.focus() this.$refs.input.focus()
@@ -68,3 +83,9 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
input[type='time']::-webkit-calendar-picker-indicator {
filter: invert(100%);
}
</style>
+3 -3
View File
@@ -34,12 +34,12 @@ export default {
computed: {}, computed: {},
methods: { methods: {
updateProgress() { updateProgress() {
var progbar = this.$refs.progressbar const progbar = this.$refs.progressbar
var circle = this.$refs.circle const circle = this.$refs.circle
if (!progbar || !circle) return if (!progbar || !circle) return
clearTimeout(this.updateTimeout) clearTimeout(this.updateTimeout)
var progress = Math.min(this.value || 0, 1) const progress = Math.min(this.value || 0, 1)
progbar.style.setProperty('--progress-percent-before', this.lastProgress) progbar.style.setProperty('--progress-percent-before', this.lastProgress)
progbar.style.setProperty('--progress-percent', progress) progbar.style.setProperty('--progress-percent', progress)
@@ -1,6 +1,6 @@
<template> <template>
<div v-if="numPartsRemaining > 0"> <div v-if="downloadItemPartsRemaining.length" @click="clickedIt">
<widgets-circle-progress :value="progress" :count="numPartsRemaining" /> <widgets-circle-progress :value="progress" :count="downloadItemPartsRemaining.length" />
</div> </div>
</template> </template>
@@ -10,73 +10,42 @@ import { AbsDownloader } from '@/plugins/capacitor'
export default { export default {
data() { data() {
return { return {
updateListener: null, downloadItemListener: null,
completeListener: null, completeListener: null,
itemDownloadingMap: {} itemPartUpdateListener: null
} }
}, },
computed: { computed: {
numItemPartsComplete() { downloadItems() {
var total = 0 return this.$store.state.globals.itemDownloads
Object.values(this.itemDownloadingMap).map((item) => (total += item.partsCompleted))
return total
}, },
numPartsRemaining() { downloadItemParts() {
return this.numTotalParts - this.numItemPartsComplete let parts = []
this.downloadItems.forEach((di) => parts.push(...di.downloadItemParts))
return parts
}, },
numTotalParts() { downloadItemPartsRemaining() {
var total = 0 return this.downloadItemParts.filter((dip) => !dip.completed)
Object.values(this.itemDownloadingMap).map((item) => (total += item.totalParts))
return total
}, },
progress() { progress() {
var numItems = Object.keys(this.itemDownloadingMap).length let totalBytes = 0
if (!numItems) return 0 let totalBytesDownloaded = 0
var totalProg = 0 this.downloadItemParts.forEach((dip) => {
Object.values(this.itemDownloadingMap).map((item) => (totalProg += item.itemProgress)) totalBytes += dip.fileSize
return totalProg / numItems totalBytesDownloaded += dip.bytesDownloaded
})
if (!totalBytes) return 0
return Math.min(1, totalBytesDownloaded / totalBytes)
},
isIos() {
return this.$platform === 'ios'
} }
}, },
methods: { methods: {
onItemDownloadUpdate(data) { clickedIt() {
console.log('DownloadProgressIndicator onItemDownloadUpdate', JSON.stringify(data)) if (this.isIos) return // TODO: Implement on iOS
if (!data || !data.downloadItemParts) { this.$router.push('/downloading')
console.error('Invalid item update payload')
return
}
var downloadItemParts = data.downloadItemParts
var partsCompleted = 0
var totalPartsProgress = 0
var partsRemaining = 0
downloadItemParts.forEach((dip) => {
if (dip.completed) {
totalPartsProgress += 1
partsCompleted++
} else {
var progPercent = dip.progress / 100
totalPartsProgress += progPercent
partsRemaining++
}
})
var itemProgress = totalPartsProgress / downloadItemParts.length
var update = {
id: data.id,
libraryItemId: data.libraryItemId,
partsRemaining,
partsCompleted,
totalParts: downloadItemParts.length,
itemProgress
}
data.itemProgress = itemProgress
data.episodes = downloadItemParts.filter((dip) => dip.episode).map((dip) => dip.episode)
console.log('[download] Saving item update download payload', JSON.stringify(update))
console.log('[download] Download Progress indicator data', JSON.stringify(data))
this.$set(this.itemDownloadingMap, update.id, update)
this.$store.commit('globals/addUpdateItemDownload', data)
}, },
onItemDownloadComplete(data) { onItemDownloadComplete(data) {
console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data)) console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data))
@@ -85,11 +54,6 @@ export default {
return return
} }
if (this.itemDownloadingMap[data.libraryItemId]) {
delete this.itemDownloadingMap[data.libraryItemId]
} else {
console.warn('Item download complete but not found in item downloading map', data.libraryItemId)
}
if (!data.localLibraryItem) { if (!data.localLibraryItem) {
this.$toast.error('Item download complete but failed to create library item') this.$toast.error('Item download complete but failed to create library item')
} else { } else {
@@ -103,15 +67,28 @@ export default {
} }
this.$store.commit('globals/removeItemDownload', data.libraryItemId) this.$store.commit('globals/removeItemDownload', data.libraryItemId)
},
onDownloadItem(downloadItem) {
console.log('DownloadProgressIndicator onDownloadItem', JSON.stringify(downloadItem))
downloadItem.itemProgress = 0
downloadItem.episodes = downloadItem.downloadItemParts.filter((dip) => dip.episode).map((dip) => dip.episode)
this.$store.commit('globals/addUpdateItemDownload', downloadItem)
},
onDownloadItemPartUpdate(itemPart) {
this.$store.commit('globals/updateDownloadItemPart', itemPart)
} }
}, },
mounted() { mounted() {
this.updateListener = AbsDownloader.addListener('onItemDownloadUpdate', (data) => this.onItemDownloadUpdate(data)) this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
}, },
beforeDestroy() { beforeDestroy() {
if (this.updateListener) this.updateListener.remove() if (this.downloadItemListener) this.downloadItemListener.remove()
if (this.completeListener) this.completeListener.remove() if (this.completeListener) this.completeListener.remove()
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove()
} }
} }
</script> </script>
+8 -8
View File
@@ -651,7 +651,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -705,7 +705,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -720,12 +720,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 = 17; CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.60; MARKETING_VERSION = 0.9.61;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -744,12 +744,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 = 17; CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.60; MARKETING_VERSION = 0.9.61;
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 = "";
@@ -768,7 +768,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
@@ -790,7 +790,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.AudiobookshelfUnitTests; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.AudiobookshelfUnitTests;
+8 -11
View File
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Override point for customization after application launch. // Override point for customization after application launch.
let configuration = Realm.Configuration( let configuration = Realm.Configuration(
schemaVersion: 5, schemaVersion: 6,
migrationBlock: { [weak self] migration, oldSchemaVersion in migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) { if (oldSchemaVersion < 1) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)") self?.logger.log("Realm schema version was \(oldSchemaVersion)")
@@ -36,6 +36,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
newObject?["lockOrientation"] = "NONE" newObject?["lockOrientation"] = "NONE"
} }
} }
if (oldSchemaVersion < 6) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["hapticFeedback"] = "LIGHT"
}
}
} }
) )
Realm.Configuration.defaultConfiguration = configuration Realm.Configuration.defaultConfiguration = configuration
@@ -88,15 +94,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
backgroundCompletionHandler = completionHandler backgroundCompletionHandler = completionHandler
} }
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
let statusBarRect = self.window?.windowScene?.statusBarManager?.statusBarFrame
guard let touchPoint = event?.allTouches?.first?.location(in: self.window) else { return }
if statusBarRect?.contains(touchPoint) ?? false {
NotificationCenter.default.post(name: .capacitorStatusBarTapped, object: nil)
}
}
} }
-5
View File
@@ -22,11 +22,6 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>INPlayMediaIntent</string> <string>INPlayMediaIntent</string>
+2
View File
@@ -237,12 +237,14 @@ public class AbsDatabase: CAPPlugin {
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10 let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10 let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
let lockOrientation = call.getString("lockOrientation") ?? "NONE" let lockOrientation = call.getString("lockOrientation") ?? "NONE"
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
let settings = DeviceSettings() let settings = DeviceSettings()
settings.disableAutoRewind = disableAutoRewind settings.disableAutoRewind = disableAutoRewind
settings.enableAltView = enableAltView settings.enableAltView = enableAltView
settings.jumpBackwardsTime = jumpBackwardsTime settings.jumpBackwardsTime = jumpBackwardsTime
settings.jumpForwardTime = jumpForwardTime settings.jumpForwardTime = jumpForwardTime
settings.lockOrientation = lockOrientation settings.lockOrientation = lockOrientation
settings.hapticFeedback = hapticFeedback
Database.shared.setDeviceSettings(deviceSettings: settings) Database.shared.setDeviceSettings(deviceSettings: settings)
+9 -2
View File
@@ -1,4 +1,6 @@
platform :ios, '12.0' require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
use_frameworks! use_frameworks!
# workaround to avoid Xcode caching of Pods that requires # workaround to avoid Xcode caching of Pods that requires
@@ -13,8 +15,8 @@ def capacitor_pods
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog' pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
end end
@@ -25,3 +27,8 @@ target 'Audiobookshelf' do
pod 'RealmSwift', '~> 10' pod 'RealmSwift', '~> 10'
pod 'Alamofire', '~> 5.5' pod 'Alamofire', '~> 5.5'
end end
post_install do |installer|
assertDeploymentTarget(installer)
end
+4 -2
View File
@@ -10,10 +10,11 @@ import RealmSwift
class DeviceSettings: Object { class DeviceSettings: Object {
@Persisted var disableAutoRewind: Bool = false @Persisted var disableAutoRewind: Bool = false
@Persisted var enableAltView: Bool = false @Persisted var enableAltView: Bool = true
@Persisted var jumpBackwardsTime: Int = 10 @Persisted var jumpBackwardsTime: Int = 10
@Persisted var jumpForwardTime: Int = 10 @Persisted var jumpForwardTime: Int = 10
@Persisted var lockOrientation: String = "NONE" @Persisted var lockOrientation: String = "NONE"
@Persisted var hapticFeedback: String = "LIGHT"
} }
func getDefaultDeviceSettings() -> DeviceSettings { func getDefaultDeviceSettings() -> DeviceSettings {
@@ -26,6 +27,7 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
"enableAltView": settings.enableAltView, "enableAltView": settings.enableAltView,
"jumpBackwardsTime": settings.jumpBackwardsTime, "jumpBackwardsTime": settings.jumpBackwardsTime,
"jumpForwardTime": settings.jumpForwardTime, "jumpForwardTime": settings.jumpForwardTime,
"lockOrientation": settings.lockOrientation "lockOrientation": settings.lockOrientation,
"hapticFeedback": settings.hapticFeedback
] ]
} }
+47 -18
View File
@@ -37,6 +37,7 @@ class AudioPlayer: NSObject {
private var sessionId: String private var sessionId: String
private var timeObserverToken: Any? private var timeObserverToken: Any?
private var sleepTimerObserverToken: Any?
private var queueObserver:NSKeyValueObservation? private var queueObserver:NSKeyValueObservation?
private var queueItemStatusObserver:NSKeyValueObservation? private var queueItemStatusObserver:NSKeyValueObservation?
@@ -96,7 +97,7 @@ class AudioPlayer: NSObject {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last) self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
} }
setupTimeObserver() setupTimeObservers()
setupQueueObserver() setupQueueObserver()
setupQueueItemStatusObserver() setupQueueItemStatusObserver()
@@ -130,7 +131,7 @@ class AudioPlayer: NSObject {
// Remove observers // Remove observers
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext) self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext)
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext) self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
self.removeTimeObserver() self.removeTimeObservers()
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
@@ -170,15 +171,15 @@ class AudioPlayer: NSObject {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance()) NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
} }
private func setupTimeObserver() { private func setupTimeObservers() {
// Time observer should be configured on the main queue // Time observer should be configured on the main queue
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
self.removeTimeObserver() self.removeTimeObservers()
let timeScale = CMTimeScale(NSEC_PER_SEC) let timeScale = CMTimeScale(NSEC_PER_SEC)
// Rate will be different depending on playback speed, aim for 2 observations/sec // Save the current time every 15 seconds
let seconds = 0.5 * (self.rate > 0 ? self.rate : 1.0) var seconds = 15.0
let time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale) var time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
guard let self = self else { return } guard let self = self else { return }
guard self.isInitialized() else { return } guard self.isInitialized() else { return }
@@ -190,7 +191,12 @@ class AudioPlayer: NSObject {
// Let the player update the current playback positions // Let the player update the current playback positions
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: isPlaying, isStopping: false) await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: isPlaying, isStopping: false)
} }
}
// Update the sleep timer every second
seconds = 1.0
time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
self.sleepTimerObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
guard let self = self else { return }
if self.isSleepTimerSet() { if self.isSleepTimerSet() {
// Update the UI // Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
@@ -199,11 +205,15 @@ class AudioPlayer: NSObject {
} }
} }
private func removeTimeObserver() { private func removeTimeObservers() {
if let timeObserverToken = timeObserverToken { if let timeObserverToken = timeObserverToken {
self.audioPlayer.removeTimeObserver(timeObserverToken) self.audioPlayer.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil self.timeObserverToken = nil
} }
if let sleepTimerObserverToken = sleepTimerObserverToken {
self.audioPlayer.removeTimeObserver(sleepTimerObserverToken)
self.sleepTimerObserverToken = nil
}
} }
private func setupQueueObserver() { private func setupQueueObserver() {
@@ -426,7 +436,7 @@ class AudioPlayer: NSObject {
self.tmpRate = rate self.tmpRate = rate
// Setup the time observer again at the new rate // Setup the time observer again at the new rate
self.setupTimeObserver() self.setupTimeObservers()
} }
} }
@@ -462,18 +472,18 @@ class AudioPlayer: NSObject {
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
// The only reason this is separate is because the filename needs to be encoded // The only reason this is separate is because the filename needs to be encoded
let filename = track.metadata?.filename ?? "" let relPath = track.metadata?.relPath ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)! let url = URL(string: urlstr)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) { } else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else { guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file // Worst case we can stream the file
logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")") logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let filename = track.metadata?.filename ?? "" let relPath = track.metadata?.relPath ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)! let url = URL(string: urlstr)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
} }
@@ -561,6 +571,8 @@ class AudioPlayer: NSObject {
} }
let commandCenter = MPRemoteCommandCenter.shared() let commandCenter = MPRemoteCommandCenter.shared()
let deviceSettings = Database.shared.getDeviceSettings() let deviceSettings = Database.shared.getDeviceSettings()
let jumpForwardTime = deviceSettings.jumpForwardTime
let jumpBackwardsTime = deviceSettings.jumpBackwardsTime
commandCenter.playCommand.isEnabled = true commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] event in commandCenter.playCommand.addTarget { [weak self] event in
@@ -574,7 +586,7 @@ class AudioPlayer: NSObject {
} }
commandCenter.skipForwardCommand.isEnabled = true commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)] commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: jumpForwardTime)]
commandCenter.skipForwardCommand.addTarget { [weak self] event in commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let command = event.command as? MPSkipIntervalCommand else { guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent return .noSuchContent
@@ -586,7 +598,7 @@ class AudioPlayer: NSObject {
return .success return .success
} }
commandCenter.skipBackwardCommand.isEnabled = true commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)] commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: jumpBackwardsTime)]
commandCenter.skipBackwardCommand.addTarget { [weak self] event in commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let command = event.command as? MPSkipIntervalCommand else { guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent return .noSuchContent
@@ -598,6 +610,23 @@ class AudioPlayer: NSObject {
return .success return .success
} }
commandCenter.nextTrackCommand.isEnabled = true
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
guard let currentTime = self?.getCurrentTime() else {
return .commandFailed
}
self?.seek(currentTime + Double(jumpForwardTime), from: "remote")
return .success
}
commandCenter.previousTrackCommand.isEnabled = true
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
guard let currentTime = self?.getCurrentTime() else {
return .commandFailed
}
self?.seek(currentTime - Double(jumpBackwardsTime), from: "remote")
return .success
}
commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else { guard let event = event as? MPChangePlaybackPositionCommandEvent else {
+6 -1
View File
@@ -12,7 +12,7 @@ import RealmSwift
class PlayerProgress { class PlayerProgress {
public static let shared = PlayerProgress() public static let shared = PlayerProgress()
private static let TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 10.0 private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
private let logger = AppLogger(category: "PlayerProgress") private let logger = AppLogger(category: "PlayerProgress")
@@ -117,6 +117,11 @@ class PlayerProgress {
} }
private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws { private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws {
if ProcessInfo.processInfo.isLowPowerModeEnabled == true {
PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 60.0
} else {
PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
}
var safeToSync = true var safeToSync = true
guard var session = session.thaw() else { return } guard var session = session.thaw() else { return }
+16
View File
@@ -243,12 +243,28 @@ class ApiClient {
callback(obj) callback(obj)
} }
} }
public static func pingServer() async -> Bool {
var status = true
AF.request("\(Store.serverConfig!.address)/ping", method: .get).responseDecodable(of: PingResponsePayload.self) { response in
switch response.result {
case .success:
status = true
case .failure:
status = false
}
}
return status
}
} }
struct LocalMediaProgressSyncPayload: Codable { struct LocalMediaProgressSyncPayload: Codable {
var localMediaProgress: [LocalMediaProgress] var localMediaProgress: [LocalMediaProgress]
} }
struct PingResponsePayload: Codable {
var success: Bool
}
struct MediaProgressSyncResponsePayload: Decodable { struct MediaProgressSyncResponsePayload: Decodable {
var numServerProgressUpdates: Int? var numServerProgressUpdates: Int?
var localProgressUpdates: [LocalMediaProgress]? var localProgressUpdates: [LocalMediaProgress]?
+38 -20
View File
@@ -76,15 +76,12 @@ export default {
} }
}, },
async loadSavedSettings() { async loadSavedSettings() {
var userSavedServerSettings = await this.$localStore.getServerSettings() const userSavedServerSettings = await this.$localStore.getServerSettings()
if (userSavedServerSettings) { if (userSavedServerSettings) {
this.$store.commit('setServerSettings', userSavedServerSettings) this.$store.commit('setServerSettings', userSavedServerSettings)
} }
var userSavedSettings = await this.$localStore.getUserSettings() await this.$store.dispatch('user/loadUserSettings')
if (userSavedSettings) {
this.$store.commit('user/setSettings', userSavedSettings)
}
}, },
async attemptConnection() { async attemptConnection() {
console.warn('[default] attemptConnection') console.warn('[default] attemptConnection')
@@ -97,11 +94,16 @@ export default {
} }
this.attemptingConnection = true this.attemptingConnection = true
var deviceData = await this.$db.getDeviceData() const deviceData = await this.$db.getDeviceData()
var serverConfig = null let serverConfig = null
if (deviceData && deviceData.lastServerConnectionConfigId && deviceData.serverConnectionConfigs.length) { if (deviceData) {
this.$store.commit('globals/setHapticFeedback', deviceData.hapticFeedback)
if (deviceData.lastServerConnectionConfigId && deviceData.serverConnectionConfigs.length) {
serverConfig = deviceData.serverConnectionConfigs.find((scc) => scc.id == deviceData.lastServerConnectionConfigId) serverConfig = deviceData.serverConnectionConfigs.find((scc) => scc.id == deviceData.lastServerConnectionConfigId)
} }
}
if (!serverConfig) { if (!serverConfig) {
// No last server config set // No last server config set
this.attemptingConnection = false this.attemptingConnection = false
@@ -110,9 +112,9 @@ export default {
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`) console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
var authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` }, timeout: 3000 }).catch((error) => { const authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` }, timeout: 3000 }).catch((error) => {
console.error('[Server] Server auth failed', error) console.error('[Server] Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg this.error = errorMsg
return false return false
}) })
@@ -125,13 +127,13 @@ export default {
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
// Set library - Use last library if set and available fallback to default user library // Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId() const lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId) this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (userDefaultLibraryId) { } else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
} }
var serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig) const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
@@ -181,12 +183,19 @@ export default {
this.$store.commit('setLastLocalMediaSyncResults', null) this.$store.commit('setLastLocalMediaSyncResults', null)
return return
} }
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates, serverProgressUpdates } = response
if (numLocalMediaProgressForServer > 0) { if (numLocalMediaProgressForServer > 0) {
response.syncedAt = Date.now() response.syncedAt = Date.now()
response.serverConfigName = this.$store.getters['user/getServerConfigName'] response.serverConfigName = this.$store.getters['user/getServerConfigName']
this.$store.commit('setLastLocalMediaSyncResults', response) this.$store.commit('setLastLocalMediaSyncResults', response)
if (serverProgressUpdates && serverProgressUpdates.length) {
serverProgressUpdates.forEach((progress) => {
console.log(`[default] Server progress was updated ${progress.id}`)
this.$store.commit('user/updateUserMediaProgress', progress)
})
}
if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) { if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) {
console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`) console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`)
} else { } else {
@@ -197,7 +206,8 @@ export default {
this.$store.commit('setLastLocalMediaSyncResults', null) this.$store.commit('setLastLocalMediaSyncResults', null)
} }
}, },
async userUpdated(user) { userUpdated(user) {
console.log('[default] userUpdated:', JSON.stringify(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)
} }
@@ -206,11 +216,19 @@ export default {
console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`) console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`)
// Update local media progress if exists // Update local media progress if exists
var localProg = this.$store.getters['globals/getLocalMediaProgressByServerItemId'](prog.libraryItemId, prog.episodeId) const localProg = await this.$db.getLocalMediaProgressForServerItem({ libraryItemId: prog.libraryItemId, episodeId: prog.episodeId })
var newLocalMediaProgress = null
let newLocalMediaProgress = null
if (localProg && localProg.lastUpdate < prog.lastUpdate) { if (localProg && localProg.lastUpdate < prog.lastUpdate) {
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
console.log('[default] syncing progress server lastUpdate > local lastUpdate but currentTime and isFinished is equal')
return
} else {
console.log(`[default] syncing progress server lastUpdate > local lastUpdate. server currentTime=${prog.currentTime} local currentTime=${localProg.currentTime} | server/local isFinished=${prog.isFinished}/${localProg.isFinished}`)
}
// Server progress is more up-to-date // 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}` : ''}`) console.log(`[default] syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}`)
const payload = { const payload = {
localMediaProgressId: localProg.id, localMediaProgressId: localProg.id,
mediaProgress: prog mediaProgress: prog
@@ -219,12 +237,12 @@ export default {
} else if (!localProg) { } else if (!localProg) {
// Check if local library item exists // Check if local library item exists
// local media progress may not exist yet if it hasn't been played // local media progress may not exist yet if it hasn't been played
var localLibraryItem = await this.$db.getLocalLibraryItemByLId(prog.libraryItemId) const localLibraryItem = await this.$db.getLocalLibraryItemByLId(prog.libraryItemId)
if (localLibraryItem) { if (localLibraryItem) {
if (prog.episodeId) { if (prog.episodeId) {
// If episode check if local episode exists // If episode check if local episode exists
var lliEpisodes = localLibraryItem.media.episodes || [] const lliEpisodes = localLibraryItem.media.episodes || []
var localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId) const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
if (localEpisode) { if (localEpisode) {
// Add new local media progress // Add new local media progress
const payload = { const payload = {
+245 -19017
View File
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.60-beta", "version": "0.9.61-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
@@ -12,21 +12,21 @@
"ionic:serve": "npm run start" "ionic:serve": "npm run start"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^3.4.3", "@capacitor/android": "^4.0.0",
"@capacitor/app": "^1.1.1", "@capacitor/app": "^4.0.0",
"@capacitor/cli": "^3.4.3", "@capacitor/core": "^4.0.0",
"@capacitor/core": "^3.4.3", "@capacitor/dialog": "^4.0.0",
"@capacitor/dialog": "^1.0.7", "@capacitor/haptics": "^4.0.0",
"@capacitor/haptics": "^1.1.4", "@capacitor/ios": "^4.0.0",
"@capacitor/ios": "^3.2.2", "@capacitor/network": "^4.0.0",
"@capacitor/network": "^1.0.7", "@capacitor/preferences": "^4.0.2",
"@capacitor/status-bar": "^1.0.8", "@capacitor/status-bar": "^4.0.0",
"@capacitor/storage": "^1.2.5",
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"cordova-plugin-screen-orientation": "^3.0.2", "cordova-plugin-screen-orientation": "^3.0.2",
"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",
"fast-average-color": "^9.3.0",
"libarchive.js": "^1.3.0", "libarchive.js": "^1.3.0",
"nuxt": "^2.15.7", "nuxt": "^2.15.7",
"socket.io-client": "^4.1.3", "socket.io-client": "^4.1.3",
@@ -37,6 +37,7 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.13.15", "@babel/core": "7.13.15",
"@babel/preset-env": "7.13.15", "@babel/preset-env": "7.13.15",
"@capacitor/cli": "^4.0.0",
"@nuxtjs/tailwindcss": "^4.2.0", "@nuxtjs/tailwindcss": "^4.2.0",
"postcss": "^8.3.5" "postcss": "^8.3.5"
} }
+1 -1
View File
@@ -53,7 +53,7 @@ export default {
}, },
methods: { methods: {
async logout() { async logout() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.user) { if (this.user) {
await this.$axios.$post('/logout').catch((error) => { await this.$axios.$post('/logout').catch((error) => {
console.error(error) console.error(error)
+124 -18
View File
@@ -38,14 +38,36 @@ export default {
return { return {
shelves: [], shelves: [],
loading: false, loading: false,
isFirstNetworkConnection: true,
lastServerFetch: 0,
lastServerFetchLibraryId: null,
lastLocalFetch: 0,
localLibraryItems: [] localLibraryItems: []
} }
}, },
watch: { watch: {
networkConnected(newVal) { networkConnected(newVal) {
// Update shelves when network connect status changes // Update shelves when network connect status changes
console.log(`Network changed to ${newVal} - fetch categories`) console.log(`[categories] Network changed to ${newVal} - fetch categories. ${this.lastServerFetch}/${this.lastLocalFetch}`)
if (newVal) {
// Fetch right away the first time network connects
if (this.isFirstNetworkConnection) {
this.isFirstNetworkConnection = false
console.log(`[categories] networkConnected true first network connection. lastServerFetch=${this.lastServerFetch}`)
this.fetchCategories() this.fetchCategories()
return
}
setTimeout(() => {
// Using timeout because making this fetch as soon as network gets connected will often fail on Android
console.log(`[categories] networkConnected true so fetching categories. lastServerFetch=${this.lastServerFetch}`)
this.fetchCategories()
}, 4000)
} else {
console.log(`[categories] networkConnected false so fetching categories`)
this.fetchCategories()
}
} }
}, },
computed: { computed: {
@@ -64,6 +86,9 @@ export default {
currentLibraryMediaType() { currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
}, },
currentLibraryIsPodcast() {
return this.currentLibraryMediaType === 'podcast'
},
altViewEnabled() { altViewEnabled() {
return this.$store.getters['getAltViewEnabled'] return this.$store.getters['getAltViewEnabled']
}, },
@@ -73,22 +98,66 @@ export default {
}, },
methods: { methods: {
async getLocalMediaItemCategories() { async getLocalMediaItemCategories() {
var localMedia = await this.$db.getLocalLibraryItems() const localMedia = await this.$db.getLocalLibraryItems()
console.log('Got local library items', localMedia ? localMedia.length : 'N/A')
if (!localMedia || !localMedia.length) return [] if (!localMedia || !localMedia.length) return []
var categories = [] const categories = []
var books = [] const books = []
var podcasts = [] const podcasts = []
const booksContinueListening = []
const podcastEpisodesContinueListening = []
localMedia.forEach((item) => { localMedia.forEach((item) => {
if (item.mediaType == 'book') { if (item.mediaType == 'book') {
item.progress = this.localMediaProgress.find((lmp) => lmp.id === item.id) item.progress = this.$store.getters['globals/getLocalMediaProgressById'](item.id)
if (item.progress && !item.progress.isFinished && item.progress.progress > 0) booksContinueListening.push(item)
books.push(item) books.push(item)
} else if (item.mediaType == 'podcast') { } else if (item.mediaType == 'podcast') {
const podcastEpisodeItemCloner = { ...item }
item.media.episodes = item.media.episodes.map((ep) => {
ep.progress = this.$store.getters['globals/getLocalMediaProgressById'](item.id, ep.id)
if (ep.progress && !ep.progress.isFinished && ep.progress.progress > 0) {
podcastEpisodesContinueListening.push({
...podcastEpisodeItemCloner,
recentEpisode: ep
})
}
return ep
})
podcasts.push(item) podcasts.push(item)
} }
}) })
// Local continue listening shelves, only shown offline
if (booksContinueListening.length) {
categories.push({
id: 'local-books-continue',
label: 'Continue Books',
type: 'book',
localOnly: true,
entities: booksContinueListening.sort((a, b) => {
if (a.progress && b.progress) {
return b.progress.lastUpdate > a.progress.lastUpdate ? 1 : -1
}
return 0
})
})
}
if (podcastEpisodesContinueListening.length) {
categories.push({
id: 'local-episodes-continue',
label: 'Continue Episodes',
type: 'episode',
localOnly: true,
entities: podcastEpisodesContinueListening.sort((a, b) => {
if (a.recentEpisode.progress && b.recentEpisode.progress) {
return b.recentEpisode.progress.lastUpdate > a.recentEpisode.progress.lastUpdate ? 1 : -1
}
return 0
})
})
}
// Local books and local podcast shelves
if (books.length) { if (books.length) {
categories.push({ categories.push({
id: 'local-books', id: 'local-books',
@@ -97,6 +166,9 @@ export default {
entities: books.sort((a, b) => { entities: books.sort((a, b) => {
if (a.progress && a.progress.isFinished) return 1 if (a.progress && a.progress.isFinished) return 1
else if (b.progress && b.progress.isFinished) return -1 else if (b.progress && b.progress.isFinished) return -1
else if (a.progress && b.progress) {
return b.progress.lastUpdate > a.progress.lastUpdate ? 1 : -1
}
return 0 return 0
}) })
}) })
@@ -113,23 +185,53 @@ export default {
return categories return categories
}, },
async fetchCategories() { async fetchCategories() {
if (this.loading) { console.log(`[categories] fetchCategories networkConnected=${this.networkConnected}, lastServerFetch=${this.lastServerFetch}, lastLocalFetch=${this.lastLocalFetch}`)
console.log('Already loading categories')
// TODO: Find a better way to keep the shelf up-to-date with local vs server library because this is a disaster
if (this.user && this.currentLibraryId && this.networkConnected) {
if (this.lastServerFetch && Date.now() - this.lastServerFetch < 5000 && this.lastServerFetchLibraryId == this.currentLibraryId) {
console.log(`[categories] fetchCategories server fetch was ${Date.now() - this.lastServerFetch}ms ago so not doing it.`)
return return
} else {
console.log(`[categories] fetchCategories fetching from server. Last was ${this.lastServerFetch ? Date.now() - this.lastServerFetch + 'ms' : 'Never'} ago. lastServerFetchLibraryId=${this.lastServerFetchLibraryId} and currentLibraryId=${this.currentLibraryId}`)
this.lastServerFetchLibraryId = this.currentLibraryId
this.lastServerFetch = Date.now()
this.lastLocalFetch = 0
} }
} else {
if (this.lastLocalFetch && Date.now() - this.lastLocalFetch < 5000) {
console.log(`[categories] fetchCategories local fetch was ${Date.now() - this.lastLocalFetch}ms ago so not doing it.`)
return
} else {
console.log(`[categories] fetchCategories fetching from local. Last was ${this.lastLocalFetch ? Date.now() - this.lastLocalFetch + 'ms' : 'Never'} ago`)
this.lastServerFetchLibraryId = null
this.lastServerFetch = 0
this.lastLocalFetch = Date.now()
}
}
this.loading = true this.loading = true
this.shelves = [] this.shelves = []
if (this.user && this.currentLibraryId && this.networkConnected) {
this.localLibraryItems = await this.$db.getLocalLibraryItems() this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories() const localCategories = await this.getLocalMediaItemCategories()
if (this.user && this.currentLibraryId) {
const categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`).catch((error) => { const categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`).catch((error) => {
console.error('Failed to fetch categories', error) console.error('[categories] Failed to fetch categories', error)
return [] return []
}) })
if (!categories.length) {
// Failed to load categories so use local shelves
console.warn(`[categories] Failed to get server categories so using local categories`)
this.shelves = localCategories
this.lastServerFetch = 0
this.lastLocalFetch = Date.now()
this.loading = false
console.log('[categories] Local shelves set from failure', this.shelves.length, this.lastLocalFetch)
return
}
this.shelves = categories.map((cat) => { this.shelves = categories.map((cat) => {
console.log('[breadcrumb] Personalized category from server', cat.type)
if (cat.type == 'book' || cat.type == 'podcast' || cat.type == 'episode') { if (cat.type == 'book' || cat.type == 'podcast' || cat.type == 'episode') {
// Map localLibraryItem to entities // Map localLibraryItem to entities
cat.entities = cat.entities.map((entity) => { cat.entities = cat.entities.map((entity) => {
@@ -146,29 +248,32 @@ export default {
}) })
// Only add the local shelf with the same media type // Only add the local shelf with the same media type
const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType) const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType && !cat.localOnly)
this.shelves.push(...localShelves) this.shelves.push(...localShelves)
console.log('[categories] Server shelves set', this.shelves.length, this.lastServerFetch)
} else { } else {
// Offline only local // Offline only local
this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories()
this.shelves = localCategories this.shelves = localCategories
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
} }
this.loading = false this.loading = false
}, },
async libraryChanged() { libraryChanged() {
if (this.currentLibraryId) { if (this.currentLibraryId) {
await this.fetchCategories() console.log(`[categories] libraryChanged so fetching categories`)
this.fetchCategories()
} }
}, },
audiobookAdded(audiobook) { audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
// TODO: Check if audiobook would be on this shelf // TODO: Check if audiobook would be on this shelf
if (!this.search) { if (!this.search) {
this.fetchCategories() this.fetchCategories()
} }
}, },
audiobookUpdated(audiobook) { audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
this.shelves.forEach((shelf) => { this.shelves.forEach((shelf) => {
if (shelf.type === 'books') { if (shelf.type === 'books') {
shelf.entities = shelf.entities.map((ent) => { shelf.entities = shelf.entities.map((ent) => {
@@ -211,6 +316,7 @@ export default {
}, },
mounted() { mounted() {
this.initListeners() this.initListeners()
console.log(`[categories] mounted so fetching categories`)
this.fetchCategories() this.fetchCategories()
}, },
beforeDestroy() { beforeDestroy() {
+1 -1
View File
@@ -3,7 +3,7 @@
<h1 class="text-xl mb-2 font-semibold">Latest Episodes</h1> <h1 class="text-xl mb-2 font-semibold">Latest Episodes</h1>
<template v-for="episode in recentEpisodes"> <template v-for="episode in recentEpisodes">
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="episode.libraryItemId" :local-library-item-id="null" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" /> <tables-podcast-latest-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="episode.libraryItemId" :local-library-item-id="null" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
</template> </template>
</div> </div>
</template> </template>
+16 -3
View File
@@ -4,7 +4,6 @@
<div class="w-full mx-auto py-5 px-2"> <div class="w-full mx-auto py-5 px-2">
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<ui-text-input v-model="searchInput" :disabled="processing || !networkConnected" placeholder="Enter search term or RSS feed URL" text-size="sm" /> <ui-text-input v-model="searchInput" :disabled="processing || !networkConnected" placeholder="Enter search term or RSS feed URL" text-size="sm" />
<!-- <ui-btn type="submit" :disabled="processing" small>Submit</ui-btn> -->
</form> </form>
</div> </div>
@@ -132,14 +131,28 @@ export default {
this.selectedPodcast = podcast this.selectedPodcast = podcast
this.showSelectedFeed = true this.showSelectedFeed = true
console.log('Got podcast feed', payload.podcast) console.log('Got podcast feed', payload.podcast)
},
libraryChanged() {
const libraryMediaType = this.$store.getters['libraries/getCurrentLibraryMediaType']
if (libraryMediaType !== 'podcast') {
this.$router.replace('/bookshelf')
}
} }
}, },
mounted() {} mounted() {
this.$eventBus.$on('library-changed', this.libraryChanged)
},
beforeDestroy() {
this.$eventBus.$off('library-changed', this.libraryChanged)
}
} }
</script> </script>
<style scoped> <style scoped>
.search-results-container { .search-results-container {
max-height: calc(100vh - 180px); max-height: calc(100vh - 182px);
}
.playerOpen .search-results-container {
max-height: calc(100vh - 282px);
} }
</style> </style>
+2 -87
View File
@@ -5,94 +5,9 @@
<script> <script>
export default { export default {
data() { data() {
return { return {}
groupsPerRow: 2,
booksPerRow: 3,
selectedSeriesName: null
}
},
watch: {
routeQuery: {
handler(newVal) {
if (newVal && newVal.series) {
console.log('Select series')
this.selectedSeriesName = this.$decode(newVal.series)
} else {
this.selectedSeriesName = null
}
}
}
},
computed: {
routeQuery() {
return this.$route.query
},
series() {
return this.$store.getters['audiobooks/getSeriesGroups']()
},
seriesShelves() {
var shelves = []
var shelf = {
id: 0,
groups: []
}
for (let i = 0; i < this.series.length; i++) {
var shelfNum = Math.floor((i + 1) / this.groupsPerRow)
shelf.id = shelfNum
shelf.groups.push(this.series[i])
if ((i + 1) % this.groupsPerRow === 0) {
shelves.push(shelf)
shelf = {
id: 0,
groups: []
}
}
}
if (shelf.groups.length) {
shelves.push(shelf)
}
return shelves
},
selectedSeries() {
if (!this.selectedSeriesName) return null
return this.series.find((s) => s.name === this.selectedSeriesName)
},
seriesBooksShelves() {
if (!this.selectedSeries) return []
var seriesBooks = this.selectedSeries.books || []
var shelves = []
var shelf = {
id: 0,
books: []
}
for (let i = 0; i < seriesBooks.length; i++) {
var shelfNum = Math.floor((i + 1) / this.booksPerRow)
shelf.id = shelfNum
shelf.books.push(seriesBooks[i])
if ((i + 1) % this.booksPerRow === 0) {
shelves.push(shelf)
shelf = {
id: 0,
books: []
}
}
}
if (shelf.books.length) {
shelves.push(shelf)
}
return shelves
},
shelves() {
if (this.selectedSeries) {
return this.seriesBooksShelves
} else {
return this.seriesShelves
}
}
}, },
computed: {},
methods: {}, methods: {},
mounted() {} mounted() {}
} }
+43
View File
@@ -0,0 +1,43 @@
<template>
<div class="w-full h-full py-6 px-4 overflow-y-auto">
<p class="mb-4 text-base text-white">Downloading Files ({{ downloadItemParts.length }})</p>
<div v-if="!downloadItemParts.length" class="py-6 text-center text-lg">No download item parts</div>
<template v-for="(itemPart, num) in downloadItemParts">
<div :key="itemPart.id" class="w-full">
<div class="flex">
<div class="w-14">
<span v-if="itemPart.completed" class="material-icons text-success">check_circle_outline</span>
<span v-else class="font-semibold text-gray-200">{{ Math.round(itemPart.progress) }}%</span>
</div>
<div class="flex-grow px-2">
<p class="truncate">{{ itemPart.filename }}</p>
</div>
</div>
<div v-if="num + 1 < downloadItemParts.length" class="flex border-t border-white border-opacity-10 my-3" />
</div>
</template>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
downloadItems() {
return this.$store.state.globals.itemDownloads
},
downloadItemParts() {
let parts = []
this.downloadItems.forEach((di) => parts.push(...di.downloadItemParts))
return parts
}
},
mounted() {},
beforeDestroy() {}
}
</script>
+160 -94
View File
@@ -1,67 +1,102 @@
<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 relative bg-bg">
<div class="w-full flex justify-center relative mb-2"> <div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10">
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
<div class="w-full h-full absolute top-0 left-0" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%)" />
</div>
<div class="z-10 relative">
<div class="w-full flex justify-center relative mb-4">
<div class="relative" @click="showFullscreenCover = true"> <div class="relative" @click="showFullscreenCover = true">
<covers-book-cover :library-item="libraryItem" :width="175" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
<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: 175 * 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: coverWidth * progressPercent + 'px' }"></div>
</div>
</div> </div>
<button class="absolute top-0 right-0 px-1 outline-none" @click="moreButtonPress"> <h1 class="text-xl font-semibold">{{ title }}</h1>
<span class="material-icons text-xl">more_vert</span>
</button>
</div>
<h1 class="text-lg font-semibold">{{ title }}</h1> <p v-if="subtitle" class="text-gray-100 text-base py-0.5 mb-0.5">{{ subtitle }}</p>
<p v-if="subtitle" class="text-gray-100 text-sm py-0.5 mb-0.5">{{ subtitle }}</p>
<p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5"> <p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
<template v-for="(series, index) in seriesList" <template v-for="(series, index) in seriesList">
><nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`">{{ series.text }}</nuxt-link <nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span>
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span></template </template>
>
</p> </p>
<p v-if="podcastAuthor" class="text-sm text-gray-300 py-0.5">by {{ podcastAuthor }}</p> <p v-if="podcastAuthor" class="text-sm text-gray-300 py-0.5">by {{ podcastAuthor }}</p>
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5"> <p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5">
by by
<template v-for="(author, index) in bookAuthors" <template v-for="(author, index) in bookAuthors">
><nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`">{{ author.name }}</nuxt-link <nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,&nbsp;</span></template </template>
>
</p> </p>
<!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected --> <!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p> <p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p>
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p> <p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
<div v-if="narrators && narrators.length" class="flex py-0.5 mt-4"> <!-- action buttons -->
<div class="w-24"> <div>
<span class="text-white text-opacity-60 uppercase text-xs">Narrators</span> <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 class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis text-sm"> </div>
<div v-if="isLocal" class="flex mt-4 -mx-1">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mx-1" :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 mx-1" :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 color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
<span class="material-icons">more_vert</span>
</ui-btn>
</div>
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4 -mx-1">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mx-1" :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') : isPodcast ? 'Next Episode' : hasLocal ? 'Play' : 'Stream' }}</span>
</ui-btn>
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mx-1" :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 mx-1" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
</ui-btn>
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
<span class="material-icons">more_vert</span>
</ui-btn>
</div>
</div>
<div v-if="downloadItem" class="py-3">
<p class="text-center text-lg">Downloading! ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
</div>
<!-- metadata -->
<div class="grid gap-2 my-4" style="grid-template-columns: max-content auto">
<div v-if="narrators && narrators.length" class="text-white text-opacity-60 uppercase text-sm">Narrators</div>
<div v-if="narrators && narrators.length" class="truncate text-sm">
<template v-for="(narrator, index) in narrators"> <template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`">{{ narrator }}</nuxt-link <nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link><span :key="index" v-if="index < narrators.length - 1">,</span>
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template> </template>
</div> </div>
</div>
<div v-if="publishedYear" class="flex py-0.5"> <div v-if="publishedYear" class="text-white text-opacity-60 uppercase text-sm">Published</div>
<div class="w-24"> <div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
<span class="text-white text-opacity-60 uppercase text-xs">Publish Year</span>
</div> <div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">Genres</div>
<div class="text-sm"> <div v-if="genres.length" class="truncate text-sm">
{{ publishedYear }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-24">
<span class="text-white text-opacity-60 uppercase text-xs">Genres</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis text-sm">
<template v-for="(genre, index) in genres"> <template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link <nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link><span :key="index" v-if="index < genres.length - 1">,</span>
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template> </template>
</div> </div>
</div> </div>
@@ -82,47 +117,6 @@
</div> </div>
</div> </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 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 v-if="isLocal" 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 ? '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') : isPodcast ? 'Next Episode' : 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 v-if="downloadItem" class="py-3">
<p class="text-center text-lg">Downloading! ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
</div>
<div class="w-full py-4"> <div class="w-full py-4">
<p class="text-sm">{{ description }}</p> <p class="text-sm">{{ description }}</p>
</div> </div>
@@ -131,17 +125,19 @@
<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-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" /> <modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" /> <modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
</div> </div>
</div>
</template> </template>
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor' import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
import { FastAverageColor } from 'fast-average-color'
export default { export default {
async asyncData({ store, params, redirect, app }) { async asyncData({ store, params, redirect, app }) {
@@ -181,7 +177,10 @@ export default {
showSelectLocalFolder: false, showSelectLocalFolder: false,
showMoreMenu: false, showMoreMenu: false,
showDetailsModal: false, showDetailsModal: false,
showFullscreenCover: false showFullscreenCover: false,
coverRgb: 'rgb(55, 56, 56)',
coverBgIsLight: false,
windowWidth: 0
} }
}, },
computed: { computed: {
@@ -299,7 +298,7 @@ export default {
}, },
userTimeRemaining() { userTimeRemaining() {
if (!this.userItemProgress) return 0 if (!this.userItemProgress) return 0
var duration = this.userItemProgress.duration || this.duration const duration = this.userItemProgress.duration || this.duration
return duration - this.userItemProgress.currentTime return duration - this.userItemProgress.currentTime
}, },
progressPercent() { progressPercent() {
@@ -315,6 +314,7 @@ export default {
return this.isPlaying && !this.$store.state.playerIsLocal return this.isPlaying && !this.$store.state.playerIsLocal
}, },
isPlaying() { isPlaying() {
if (this.localLibraryItemId && this.$store.getters['getIsItemStreaming'](this.localLibraryItemId)) return true
return this.$store.getters['getIsItemStreaming'](this.libraryItemId) return this.$store.getters['getIsItemStreaming'](this.libraryItemId)
}, },
numTracks() { numTracks() {
@@ -359,6 +359,22 @@ export default {
}, },
moreMenuItems() { moreMenuItems() {
const items = [] const items = []
if (!this.isPodcast) {
// TODO: Implement on iOS
if (!this.isIos) {
items.push({
text: 'History',
value: 'history'
})
}
items.push({
text: this.userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished',
value: 'markFinished'
})
}
if (this.localLibraryItemId) { if (this.localLibraryItemId) {
items.push({ items.push({
text: 'Manage Local Files', text: 'Manage Local Files',
@@ -366,11 +382,6 @@ export default {
}) })
} }
items.push({
text: 'View Details',
value: 'details'
})
if (!this.isPodcast && this.serverLibraryItemId) { if (!this.isPodcast && this.serverLibraryItemId) {
items.push({ items.push({
text: 'Add to Playlist', text: 'Add to Playlist',
@@ -378,19 +389,57 @@ export default {
}) })
} }
items.push({
text: 'More Info',
value: 'details'
})
return items return items
},
coverWidth() {
let width = this.windowWidth - 94
if (width > 325) return 325
else if (width < 0) return 175
if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio
return width
},
mediaId() {
if (this.isPodcast) return null
return this.serverLibraryItemId || this.localLibraryItemId
} }
}, },
methods: { methods: {
async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return
const fac = new FastAverageColor()
fac
.getColorAsync(fullCoverUrl)
.then((color) => {
this.coverRgb = color.rgba
this.coverBgIsLight = color.isLight
})
.catch((e) => {
console.log(e)
})
},
moreMenuAction(action) { moreMenuAction(action) {
this.showMoreMenu = false this.showMoreMenu = false
if (action === 'manageLocal') { if (action === 'manageLocal') {
this.$nextTick(() => {
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`) this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
})
} else if (action === 'details') { } else if (action === 'details') {
this.showDetailsModal = true this.showDetailsModal = true
} else if (action === 'playlist') { } else if (action === 'playlist') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: null }]) this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: null }])
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true) this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
} else if (action === 'markFinished') {
if (this.isProcessingReadUpdate) return
this.toggleFinished()
} else if (action === 'history') {
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
} }
}, },
moreButtonPress() { moreButtonPress() {
@@ -401,7 +450,7 @@ export default {
}, },
async playClick() { async playClick() {
let episodeId = null let episodeId = null
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isPodcast) { if (this.isPodcast) {
this.episodes.sort((a, b) => { this.episodes.sort((a, b) => {
@@ -455,7 +504,8 @@ export default {
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId }) this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId })
}, },
async clearProgressClick() { async clearProgressClick() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: 'Are you sure you want to reset your progress?' message: 'Are you sure you want to reset your progress?'
@@ -508,7 +558,7 @@ export default {
if (!this.numTracks) { if (!this.numTracks) {
return return
} }
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (this.isIos) { if (this.isIos) {
// no local folders on iOS // no local folders on iOS
this.startDownload() this.startDownload()
@@ -576,7 +626,17 @@ export default {
} }
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
// Show confirm if item has progress since it will reset
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to mark this item as Finished?'
})
if (!value) return
}
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal) { if (this.isLocal) {
var isFinished = !this.userIsFinished var isFinished = !this.userIsFinished
@@ -624,14 +684,20 @@ export default {
if (this.libraryItem.libraryId !== libraryId) { if (this.libraryItem.libraryId !== libraryId) {
this.$router.replace('/bookshelf') this.$router.replace('/bookshelf')
} }
},
windowResized() {
this.windowWidth = window.innerWidth
} }
}, },
mounted() { mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.windowResized)
this.$eventBus.$on('library-changed', this.libraryChanged) this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
this.$socket.$on('item_updated', this.itemUpdated) this.$socket.$on('item_updated', this.itemUpdated)
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.windowResized)
this.$eventBus.$off('library-changed', this.libraryChanged) this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
this.$socket.$off('item_updated', this.itemUpdated) this.$socket.$off('item_updated', this.itemUpdated)
+2 -2
View File
@@ -256,7 +256,7 @@ export default {
this.showDialog = true this.showDialog = true
}, },
async play() { async play() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItemId }) this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItemId })
}, },
getCapImageSrc(contentUrl) { getCapImageSrc(contentUrl) {
@@ -264,7 +264,7 @@ export default {
}, },
async dialogAction(action) { async dialogAction(action) {
console.log('Dialog action', action) console.log('Dialog action', action)
await this.$hapticsImpactMedium() await this.$hapticsImpact()
if (action == 'scan') { if (action == 'scan') {
this.scanItem() this.scanItem()
} else if (action == 'rescan') { } else if (action == 'rescan') {
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="w-full h-full px-3 py-4 overflow-y-auto relative bg-bg">
<p class="mb-4 text-lg font-semibold">History for {{ displayTitle }}</p>
<div v-if="!mediaEvents.length" class="text-center py-8">
<p class="text-gray-200">No History</p>
</div>
<div v-for="(events, name) in groupedMediaEvents" :key="name" class="py-2">
<p class="my-2 text-gray-400 font-semibold">{{ name }}</p>
<div v-for="(evt, index) in events" :key="index" class="py-3 flex items-center">
<p class="text-sm text-gray-400 w-12">{{ $formatDate(evt.timestamp, 'HH:mm') }}</p>
<span class="material-icons px-1" :class="`text-${getEventColor(evt.name)}`">{{ getEventIcon(evt.name) }}</span>
<p class="text-sm text-white px-1">{{ evt.name }}</p>
<span v-if="evt.serverSyncAttempted && evt.serverSyncSuccess" class="material-icons-outlined px-1 text-base text-success">cloud_done</span>
<span v-if="evt.serverSyncAttempted && !evt.serverSyncSuccess" class="material-icons px-1 text-base text-error">error_outline</span>
<p v-if="evt.num" class="text-sm text-gray-400 italic px-1">+{{ evt.num }}</p>
<div class="flex-grow" />
<p class="text-base text-white" @click="clickPlaybackTime(evt.currentTime)">{{ $secondsToTimestampFull(evt.currentTime) }}</p>
</div>
</div>
</div>
</template>
<script>
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default {
async asyncData({ params, store, redirect, app, query }) {
const mediaItemHistory = await app.$db.getMediaItemHistory(params.id)
return {
title: query.title || 'Unknown',
mediaItemHistory
}
},
data() {
return {
onMediaItemHistoryUpdatedListener: null,
startingPlayback: false
}
},
computed: {
displayTitle() {
if (!this.mediaItemHistory) return this.title
return this.mediaItemHistory.mediaDisplayTitle
},
mediaEvents() {
if (!this.mediaItemHistory) return []
return (this.mediaItemHistory.events || []).sort((a, b) => b.timestamp - a.timestamp)
},
mediaItemIsLocal() {
return this.mediaItemHistory && this.mediaItemHistory.isLocal
},
mediaItemLibraryItemId() {
if (!this.mediaItemHistory) return null
return this.mediaItemHistory.libraryItemId
},
mediaItemEpisodeId() {
if (!this.mediaItemHistory) return null
return this.mediaItemHistory.episodeId
},
groupedMediaEvents() {
const groups = {}
const today = this.$formatDate(new Date(), 'MMM dd, yyyy')
const yesterday = this.$formatDate(Date.now() - 1000 * 60 * 60 * 24, 'MMM dd, yyyy')
let lastKey = null
let numSaves = 0
let numSyncs = 0
this.mediaEvents.forEach((evt) => {
const date = this.$formatDate(evt.timestamp, 'MMM dd, yyyy')
let include = true
let keyUpdated = false
let key = date
if (date === today) key = 'Today'
else if (date === yesterday) key = 'Yesterday'
if (!groups[key]) groups[key] = []
if (!lastKey || lastKey !== key) {
lastKey = key
keyUpdated = true
}
// Collapse saves
if (evt.name === 'Save') {
if (numSaves > 0 && !keyUpdated) {
include = false
const totalInGroup = groups[key].length
groups[key][totalInGroup - 1].num = numSaves
numSaves++
} else {
numSaves = 1
}
} else {
numSaves = 0
}
// Collapse syncs
if (evt.name === 'Sync') {
if (numSyncs > 0 && !keyUpdated) {
include = false
const totalInGroup = groups[key].length
groups[key][totalInGroup - 1].num = numSyncs
numSyncs++
} else {
numSyncs = 1
}
} else {
numSyncs = 0
}
if (include) {
groups[key].push(evt)
}
})
return groups
}
},
methods: {
async clickPlaybackTime(time) {
if (this.startingPlayback) return
this.startingPlayback = true
await this.$hapticsImpact()
console.log('Click playback time', time)
this.playAtTime(time)
setTimeout(() => {
this.startingPlayback = false
}, 1000)
},
playAtTime(startTime) {
if (this.mediaItemIsLocal) {
// Local only
this.$eventBus.$emit('play-item', { libraryItemId: this.mediaItemLibraryItemId, episodeId: this.mediaItemEpisodeId, startTime })
} else {
// Server may have local
const localProg = this.$store.getters['globals/getLocalMediaProgressByServerItemId'](this.mediaItemLibraryItemId, this.mediaItemEpisodeId)
if (localProg) {
// Has local copy so prefer
this.$eventBus.$emit('play-item', { libraryItemId: localProg.localLibraryItemId, episodeId: localProg.localEpisodeId, serverLibraryItemId: this.mediaItemLibraryItemId, serverEpisodeId: this.mediaItemEpisodeId, startTime })
} else {
// Only on server
this.$eventBus.$emit('play-item', { libraryItemId: this.mediaItemLibraryItemId, episodeId: this.mediaItemEpisodeId, startTime })
}
}
},
getEventIcon(name) {
switch (name) {
case 'Play':
return 'play_circle_filled'
case 'Pause':
return 'pause_circle_filled'
case 'Stop':
return 'stop_circle'
case 'Save':
return 'sync'
case 'Seek':
return 'commit'
case 'Sync':
return 'cloud_download'
default:
return 'info'
}
},
getEventColor(name) {
switch (name) {
case 'Play':
return 'success'
case 'Pause':
return 'gray-300'
case 'Stop':
return 'error'
case 'Save':
return 'info'
case 'Seek':
return 'gray-200'
case 'Sync':
return 'accent'
default:
return 'info'
}
},
onMediaItemHistoryUpdated(mediaItemHistory) {
if (!mediaItemHistory || !mediaItemHistory.id) {
console.error('Invalid media item history', mediaItemHistory)
return
}
if (mediaItemHistory.id !== this.mediaItemHistory.id) {
return
}
console.log('Media Item History updated')
this.mediaItemHistory = mediaItemHistory
}
},
async mounted() {
this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated)
},
beforeDestroy() {
if (this.onMediaItemHistoryUpdatedListener) this.onMediaItemHistoryUpdatedListener.remove()
}
}
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div class="px-4 py-6"> <div class="px-4 py-6">
<ui-text-input ref="input" v-model="search" @input="updateSearch" borderless placeholder="Search" bg="white bg-opacity-5" rounded="md" prepend-icon="search" text-size="base" class="w-full text-lg" /> <ui-text-input ref="input" v-model="search" @input="updateSearch" borderless placeholder="Search" bg="white bg-opacity-10" rounded="md" prepend-icon="search" text-size="base" clearable class="w-full text-lg" />
</div> </div>
<div class="w-full overflow-x-hidden overflow-y-auto search-content px-4" @click.stop> <div class="w-full overflow-x-hidden overflow-y-auto search-content px-4" @click.stop>
<div v-show="isFetching" class="w-full py-8 flex justify-center"> <div v-show="isFetching" class="w-full py-8 flex justify-center">
+180 -21
View File
@@ -1,19 +1,27 @@
<template> <template>
<div class="w-full h-full p-8"> <div class="w-full h-full px-8 py-8 overflow-y-auto">
<p class="uppercase text-xs font-semibold text-gray-300 mb-2">Display Settings</p> <!-- Display settings -->
<p class="uppercase text-xs font-semibold text-gray-300 mb-2">User Interface Settings</p>
<div class="flex items-center py-3" @click="toggleEnableAltView"> <div class="flex items-center py-3" @click="toggleEnableAltView">
<div class="w-10 flex justify-center"> <div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.enableAltView" @input="saveSettings" /> <ui-toggle-switch v-model="enableBookshelfView" @input="saveSettings" />
</div> </div>
<p class="pl-4">Alternative bookshelf view</p> <p class="pl-4">Use bookshelf view</p>
</div> </div>
<div class="flex items-center py-3" @click.stop="toggleLockOrientation"> <div class="flex items-center py-3" @click.stop="toggleLockOrientation">
<div class="w-10 flex justify-center"> <div class="w-10 flex justify-center pointer-events-none">
<ui-toggle-switch v-model="lockCurrentOrientation" @input="saveSettings" /> <ui-toggle-switch v-model="lockCurrentOrientation" />
</div> </div>
<p class="pl-4">Lock orientation</p> <p class="pl-4">Lock orientation</p>
</div> </div>
<div class="py-3 flex items-center">
<p class="pr-4 w-36">Haptic feedback</p>
<div @click.stop="showHapticFeedbackOptions">
<ui-text-input :value="hapticFeedbackOption" readonly append-icon="expand_more" style="max-width: 145px" />
</div>
</div>
<!-- Playback settings -->
<p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Playback Settings</p> <p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Playback Settings</p>
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind"> <div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind">
<div class="w-10 flex justify-center"> <div class="w-10 flex justify-center">
@@ -34,14 +42,48 @@
<p class="pl-4">Jump forwards time</p> <p class="pl-4">Jump forwards time</p>
</div> </div>
<p v-if="!isiOS" class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Sleep Timer Settings</p> <!-- Sleep timer settings -->
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableShakeToResetSleepTimer"> <template v-if="!isiOS">
<p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Sleep Timer Settings</p>
<div class="flex items-center py-3" @click="toggleDisableShakeToResetSleepTimer">
<div class="w-10 flex justify-center"> <div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" /> <ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" />
</div> </div>
<p class="pl-4">Disable shake to reset</p> <p class="pl-4">Disable shake to reset</p>
<span class="material-icons-outlined ml-2" @click.stop="showInfo('disableShakeToResetSleepTimer')">info</span> <span class="material-icons-outlined ml-2" @click.stop="showInfo('disableShakeToResetSleepTimer')">info</span>
</div> </div>
<div v-if="!settings.disableShakeToResetSleepTimer" class="py-3 flex items-center">
<p class="pr-4 w-36">Shake Sensitivity</p>
<div @click.stop="showShakeSensitivityOptions">
<ui-text-input :value="shakeSensitivityOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
</div>
</div>
<div class="flex items-center py-3" @click="toggleAutoSleepTimer">
<div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.autoSleepTimer" @input="saveSettings" />
</div>
<p class="pl-4">Auto Sleep Timer</p>
<span class="material-icons-outlined ml-2" @click.stop="showInfo('autoSleepTimer')">info</span>
</div>
</template>
<!-- Auto Sleep timer settings -->
<div v-if="settings.autoSleepTimer" class="py-3 flex items-center">
<p class="pr-4 w-36">Start Time</p>
<ui-text-input type="time" v-model="settings.autoSleepTimerStartTime" style="width: 145px; max-width: 145px" @input="autoSleepTimerTimeUpdated" />
</div>
<div v-if="settings.autoSleepTimer" class="py-3 flex items-center">
<p class="pr-4 w-36">End Time</p>
<ui-text-input type="time" v-model="settings.autoSleepTimerEndTime" style="width: 145px; max-width: 145px" @input="autoSleepTimerTimeUpdated" />
</div>
<div v-if="settings.autoSleepTimer" class="py-3 flex items-center">
<p class="pr-4 w-36">Sleep Timer</p>
<div @click.stop="showSleepTimerOptions">
<ui-text-input :value="sleepTimerLengthOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
</div>
</div>
<modals-dialog v-model="showMoreMenuDialog" :items="moreMenuItems" @action="clickMenuAction" />
<modals-sleep-timer-length-modal v-model="showSleepTimerLengthModal" @change="sleepTimerLengthModalSelection" />
</div> </div>
</template> </template>
@@ -52,24 +94,86 @@ export default {
data() { data() {
return { return {
deviceData: null, deviceData: null,
showMoreMenuDialog: false,
showSleepTimerLengthModal: false,
moreMenuSetting: '',
settings: { settings: {
disableAutoRewind: false, disableAutoRewind: false,
enableAltView: false, enableAltView: false,
jumpForwardTime: 10, jumpForwardTime: 10,
jumpBackwardsTime: 10, jumpBackwardsTime: 10,
disableShakeToResetSleepTimer: false, disableShakeToResetSleepTimer: false,
lockOrientation: 0 shakeSensitivity: 'MEDIUM',
lockOrientation: 0,
hapticFeedback: 'LIGHT',
autoSleepTimer: false,
autoSleepTimerStartTime: '22:00',
autoSleepTimerEndTime: '06:00',
sleepTimerLength: 900000 // 15 minutes
}, },
lockCurrentOrientation: false,
settingInfo: { settingInfo: {
disableShakeToResetSleepTimer: { disableShakeToResetSleepTimer: {
name: 'Disable shake to reset sleep timer', name: 'Disable shake to reset sleep timer',
message: 'The sleep timer will start fading out when 30s is remaining. Shaking your device will reset the timer if it is within 30s OR has finished less than 2 mintues ago. Enable this setting to disable that feature.' message: 'Shaking your device while the timer is running OR within 2 minutes of the timer expiring will reset the sleep timer. Enable this setting to disable shake to reset.'
},
autoSleepTimer: {
name: 'Auto Sleep Timer',
message: 'When playing media between the specified start and end times a sleep timer will automatically start.'
} }
}, },
lockCurrentOrientation: false hapticFeedbackItems: [
{
text: 'Off',
value: 'OFF'
},
{
text: 'Light',
value: 'LIGHT'
},
{
text: 'Medium',
value: 'MEDIUM'
},
{
text: 'Heavy',
value: 'HEAVY'
}
],
shakeSensitivityItems: [
{
text: 'Very Low',
value: 'VERY_LOW'
},
{
text: 'Low',
value: 'LOW'
},
{
text: 'Medium',
value: 'MEDIUM'
},
{
text: 'High',
value: 'HIGH'
},
{
text: 'Very High',
value: 'VERY_HIGH'
}
]
} }
}, },
computed: { computed: {
// This is flipped because alt view was the default until v0.9.61-beta
enableBookshelfView: {
get() {
return !this.settings.enableAltView
},
set(val) {
this.settings.enableAltView = !val
}
},
isiOS() { isiOS() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
@@ -92,9 +196,61 @@ export default {
currentJumpBackwardsTimeIndex() { currentJumpBackwardsTimeIndex() {
var index = this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime) var index = this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
return index >= 0 ? index : 1 return index >= 0 ? index : 1
},
shakeSensitivityOption() {
const item = this.shakeSensitivityItems.find((i) => i.value === this.settings.shakeSensitivity)
return item ? item.text : 'Error'
},
hapticFeedbackOption() {
const item = this.hapticFeedbackItems.find((i) => i.value === this.settings.hapticFeedback)
return item ? item.text : 'Error'
},
sleepTimerLengthOption() {
if (!this.settings.sleepTimerLength) return 'End of Chapter'
const minutes = Number(this.settings.sleepTimerLength) / 1000 / 60
return `${minutes} min`
},
moreMenuItems() {
if (this.moreMenuSetting === 'shakeSensitivity') return this.shakeSensitivityItems
else if (this.moreMenuSetting === 'hapticFeedback') return this.hapticFeedbackItems
return []
} }
}, },
methods: { methods: {
sleepTimerLengthModalSelection(value) {
this.settings.sleepTimerLength = value
this.saveSettings()
},
showSleepTimerOptions() {
this.showSleepTimerLengthModal = true
},
showHapticFeedbackOptions() {
this.moreMenuSetting = 'hapticFeedback'
this.showMoreMenuDialog = true
},
showShakeSensitivityOptions() {
this.moreMenuSetting = 'shakeSensitivity'
this.showMoreMenuDialog = true
},
clickMenuAction(action) {
this.showMoreMenuDialog = false
if (this.moreMenuSetting === 'shakeSensitivity') {
this.settings.shakeSensitivity = action
this.saveSettings()
} else if (this.moreMenuSetting === 'hapticFeedback') {
this.settings.hapticFeedback = action
this.hapticFeedbackUpdated(action)
}
},
autoSleepTimerTimeUpdated(val) {
console.log('[settings] Auto sleep timer time=', val)
if (!val) return // invalid times return falsy
this.saveSettings()
},
hapticFeedbackUpdated(val) {
this.$store.commit('globals/setHapticFeedback', val)
this.saveSettings()
},
showInfo(setting) { showInfo(setting) {
if (this.settingInfo[setting]) { if (this.settingInfo[setting]) {
Dialog.alert({ Dialog.alert({
@@ -103,6 +259,10 @@ export default {
}) })
} }
}, },
toggleAutoSleepTimer() {
this.settings.autoSleepTimer = !this.settings.autoSleepTimer
this.saveSettings()
},
toggleDisableShakeToResetSleepTimer() { toggleDisableShakeToResetSleepTimer() {
this.settings.disableShakeToResetSleepTimer = !this.settings.disableShakeToResetSleepTimer this.settings.disableShakeToResetSleepTimer = !this.settings.disableShakeToResetSleepTimer
this.saveSettings() this.saveSettings()
@@ -118,23 +278,18 @@ export default {
getCurrentOrientation() { getCurrentOrientation() {
const orientation = window.screen ? window.screen.orientation || {} : {} const orientation = window.screen ? window.screen.orientation || {} : {}
const type = orientation.type || '' const type = orientation.type || ''
console.log('getCurrentOrientation=' + type)
if (type.includes('landscape')) return 'LANDSCAPE' if (type.includes('landscape')) return 'LANDSCAPE'
return 'PORTRAIT' // default return 'PORTRAIT' // default
}, },
toggleLockOrientation() { toggleLockOrientation() {
console.log('TOGGLE LOCK ORIENTATION', this.lockCurrentOrientation)
this.lockCurrentOrientation = !this.lockCurrentOrientation this.lockCurrentOrientation = !this.lockCurrentOrientation
if (this.lockCurrentOrientation) { if (this.lockCurrentOrientation) {
console.log('CURRENT ORIENTATION=', this.getCurrentOrientation())
this.settings.lockOrientation = this.getCurrentOrientation() this.settings.lockOrientation = this.getCurrentOrientation()
} else { } else {
console.log('SETTING CURRENT ORIENTATION TO NONE')
this.settings.lockOrientation = 'NONE' this.settings.lockOrientation = 'NONE'
} }
this.$setOrientationLock(this.settings.lockOrientation) this.$setOrientationLock(this.settings.lockOrientation)
console.log('NOW SAVING SETTINGS', this.settings.lockOrientation)
this.saveSettings() this.saveSettings()
}, },
toggleJumpForward() { toggleJumpForward() {
@@ -149,9 +304,8 @@ export default {
this.saveSettings() this.saveSettings()
}, },
async saveSettings() { async saveSettings() {
await this.$hapticsImpactMedium() await this.$hapticsImpact()
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings }) const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
console.log('Saved device data', updatedDeviceData)
if (updatedDeviceData) { if (updatedDeviceData) {
this.$store.commit('setDeviceData', updatedDeviceData) this.$store.commit('setDeviceData', updatedDeviceData)
this.init() this.init()
@@ -166,11 +320,16 @@ export default {
this.settings.enableAltView = !!deviceSettings.enableAltView this.settings.enableAltView = !!deviceSettings.enableAltView
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10 this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10 this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
this.settings.disableShakeToResetSleepTimer = !!deviceSettings.disableShakeToResetSleepTimer
this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE' this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE'
console.log('INIT SETTINGS LOCK ORIENTATION=', this.settings.lockOrientation)
this.lockCurrentOrientation = this.settings.lockOrientation !== 'NONE' this.lockCurrentOrientation = this.settings.lockOrientation !== 'NONE'
this.settings.hapticFeedback = deviceSettings.hapticFeedback || 'LIGHT'
this.settings.disableShakeToResetSleepTimer = !!deviceSettings.disableShakeToResetSleepTimer
this.settings.shakeSensitivity = deviceSettings.shakeSensitivity || 'MEDIUM'
this.settings.autoSleepTimer = !!deviceSettings.autoSleepTimer
this.settings.autoSleepTimerStartTime = deviceSettings.autoSleepTimerStartTime || '22:00'
this.settings.autoSleepTimerEndTime = deviceSettings.autoSleepTimerEndTime || '06:00'
this.settings.sleepTimerLength = !isNaN(deviceSettings.sleepTimerLength) ? deviceSettings.sleepTimerLength : 900000 // 15 minutes
} }
}, },
mounted() { mounted() {
+1 -2
View File
@@ -27,7 +27,6 @@ export default function ({ $axios, store }) {
}) })
$axios.onError(error => { $axios.onError(error => {
const code = parseInt(error.response && error.response.status) console.error('Axios error code', error)
console.error('Axios error code', code)
}) })
} }
+2 -1
View File
@@ -43,7 +43,7 @@ class AbsAudioPlayerWeb extends WebPlugin {
} }
// PluginMethod // PluginMethod
async prepareLibraryItem({ libraryItemId, episodeId, playWhenReady }) { async prepareLibraryItem({ libraryItemId, episodeId, playWhenReady, startTime }) {
console.log('[AbsAudioPlayer] Prepare library item', libraryItemId) console.log('[AbsAudioPlayer] Prepare library item', libraryItemId)
if (libraryItemId.startsWith('local_')) { if (libraryItemId.startsWith('local_')) {
@@ -52,6 +52,7 @@ class AbsAudioPlayerWeb extends WebPlugin {
var route = !episodeId ? `/api/items/${libraryItemId}/play` : `/api/items/${libraryItemId}/play/${episodeId}` var route = !episodeId ? `/api/items/${libraryItemId}/play` : `/api/items/${libraryItemId}/play/${episodeId}`
var playbackSession = await $axios.$post(route, { mediaPlayer: 'html5-mobile', forceDirectPlay: true }) var playbackSession = await $axios.$post(route, { mediaPlayer: 'html5-mobile', forceDirectPlay: true })
if (playbackSession) { if (playbackSession) {
if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime
this.setAudioPlayer(playbackSession, true) this.setAudioPlayer(playbackSession, true)
} }
} }
+127 -1
View File
@@ -212,11 +212,137 @@ class AbsDatabaseWeb extends WebPlugin {
} }
async updateDeviceSettings(payload) { async updateDeviceSettings(payload) {
var deviceData = await this.getDeviceData() const deviceData = await this.getDeviceData()
deviceData.deviceSettings = payload deviceData.deviceSettings = payload
localStorage.setItem('device', JSON.stringify(deviceData)) localStorage.setItem('device', JSON.stringify(deviceData))
return deviceData return deviceData
} }
async getMediaItemHistory({ mediaId }) {
console.log('Get media item history', mediaId)
return {
id: mediaId,
mediaDisplayTitle: 'Test Book',
libraryItemId: mediaId,
episodeId: null,
isLocal: false,
serverConnectionConfigId: null,
serverAddress: null,
createdAt: Date.now(),
events: [
{
name: 'Pause',
type: 'Playback',
description: null,
currentTime: 81,
serverSyncAttempted: true,
serverSyncSuccess: true,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 22) + 13000 // 22 mins ago + 13s
},
{
name: 'Play',
type: 'Playback',
description: null,
currentTime: 68,
serverSyncAttempted: false,
serverSyncSuccess: null,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago
},
{
name: 'Pause',
type: 'Playback',
description: null,
currentTime: 68,
serverSyncAttempted: true,
serverSyncSuccess: false,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (58000) // 1 hour ago + 58s
},
{
name: 'Save',
type: 'Playback',
description: null,
currentTime: 55,
serverSyncAttempted: true,
serverSyncSuccess: true,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (45000) // 1 hour ago + 45s
},
{
name: 'Save',
type: 'Playback',
description: null,
currentTime: 40,
serverSyncAttempted: true,
serverSyncSuccess: true,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (30000) // 1 hour ago + 30s
},
{
name: 'Save',
type: 'Playback',
description: null,
currentTime: 25,
serverSyncAttempted: true,
serverSyncSuccess: true,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (15000) // 1 hour ago + 15s
},
{
name: 'Play',
type: 'Playback',
description: null,
currentTime: 10,
serverSyncAttempted: false,
serverSyncSuccess: null,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago
},
{
name: 'Stop',
type: 'Playback',
description: null,
currentTime: 10,
serverSyncAttempted: true,
serverSyncSuccess: true,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 10000 // 25 hours ago + 10s
},
{
name: 'Seek',
type: 'Playback',
description: null,
currentTime: 6,
serverSyncAttempted: true,
serverSyncSuccess: true,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 2000 // 25 hours ago + 2s
},
{
name: 'Play',
type: 'Playback',
description: null,
currentTime: 0,
serverSyncAttempted: false,
serverSyncSuccess: null,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 25) // 25 hours ago
},
{
name: 'Play',
type: 'Playback',
description: null,
currentTime: 0,
serverSyncAttempted: false,
serverSyncSuccess: null,
serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 50) // 50 hours ago
}
]
}
}
} }
const AbsDatabase = registerPlugin('AbsDatabase', { const AbsDatabase = registerPlugin('AbsDatabase', {
+8
View File
@@ -62,6 +62,10 @@ class DbService {
return AbsDatabase.getAllLocalMediaProgress().then((data) => data.value) return AbsDatabase.getAllLocalMediaProgress().then((data) => data.value)
} }
getLocalMediaProgressForServerItem(payload) {
return AbsDatabase.getLocalMediaProgressForServerItem(payload)
}
removeLocalMediaProgress(localMediaProgressId) { removeLocalMediaProgress(localMediaProgressId) {
return AbsDatabase.removeLocalMediaProgress({ localMediaProgressId }) return AbsDatabase.removeLocalMediaProgress({ localMediaProgressId })
} }
@@ -86,6 +90,10 @@ class DbService {
updateDeviceSettings(payload) { updateDeviceSettings(payload) {
return AbsDatabase.updateDeviceSettings(payload) return AbsDatabase.updateDeviceSettings(payload)
} }
getMediaItemHistory(mediaId) {
return AbsDatabase.getMediaItemHistory({ mediaId })
}
} }
export default ({ app, store }, inject) => { export default ({ app, store }, inject) => {
+12 -2
View File
@@ -4,7 +4,7 @@ import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics"
const hapticsImpactHeavy = async () => { const hapticsImpactHeavy = async () => {
await Haptics.impact({ style: ImpactStyle.Heavy }) await Haptics.impact({ style: ImpactStyle.Heavy })
} }
Vue.prototype.$hapticsImpactHeavy = hapticsImpactHeavy; Vue.prototype.$hapticsImpactHeavy = hapticsImpactHeavy
const hapticsImpactMedium = async () => { const hapticsImpactMedium = async () => {
await Haptics.impact({ style: ImpactStyle.Medium }) await Haptics.impact({ style: ImpactStyle.Medium })
@@ -19,7 +19,7 @@ Vue.prototype.$hapticsImpactLight = hapticsImpactLight
const hapticsVibrate = async () => { const hapticsVibrate = async () => {
await Haptics.vibrate() await Haptics.vibrate()
} }
Vue.prototype.$hapticsVibrate = hapticsVibrate; Vue.prototype.$hapticsVibrate = hapticsVibrate
const hapticsNotificationSuccess = async () => { const hapticsNotificationSuccess = async () => {
await Haptics.notification({ type: NotificationType.Success }) await Haptics.notification({ type: NotificationType.Success })
@@ -50,3 +50,13 @@ const hapticsSelectionEnd = async () => {
await Haptics.selectionEnd() await Haptics.selectionEnd()
} }
Vue.prototype.$hapticsSelectionEnd = hapticsSelectionEnd Vue.prototype.$hapticsSelectionEnd = hapticsSelectionEnd
export default ({ store }, inject) => {
inject('hapticsImpact', () => {
const hapticFeedback = store.state.globals.hapticFeedback
if (hapticFeedback === 'OFF') return
if (hapticFeedback === 'LIGHT') return hapticsImpactLight()
if (hapticFeedback === 'MEDIUM') return hapticsImpactMedium()
return hapticsImpactHeavy()
})
}
+16 -6
View File
@@ -5,8 +5,6 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import { formatDistance, format, addDays, isDate } from 'date-fns' import { formatDistance, format, addDays, isDate } from 'date-fns'
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
Vue.prototype.$eventBus = new Vue()
if (Capacitor.getPlatform() != 'web') { if (Capacitor.getPlatform() != 'web') {
const setStatusBarStyleDark = async () => { const setStatusBarStyleDark = async () => {
await StatusBar.setStyle({ style: Style.Dark }) await StatusBar.setStyle({ style: Style.Dark })
@@ -93,10 +91,10 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
} }
Vue.prototype.$secondsToTimestamp = (seconds) => { Vue.prototype.$secondsToTimestamp = (seconds) => {
var _seconds = seconds let _seconds = seconds
var _minutes = Math.floor(seconds / 60) let _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60 _seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60) let _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60 _minutes -= _hours * 60
_seconds = Math.floor(_seconds) _seconds = Math.floor(_seconds)
if (!_hours) { if (!_hours) {
@@ -105,6 +103,16 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
} }
Vue.prototype.$secondsToTimestampFull = (seconds) => {
let _seconds = Math.round(seconds)
let _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
let _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.floor(_seconds)
return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => { Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') { if (typeof input !== 'string') {
return false return false
@@ -206,7 +214,9 @@ Vue.prototype.$setOrientationLock = (orientationLockSetting) => {
} }
} }
export default ({ store, app }) => { export default ({ store, app }, inject) => {
inject('eventBus', new Vue())
// iOS Only // iOS Only
// backButton event does not work with iOS swipe navigation so use this workaround // backButton event does not work with iOS swipe navigation so use this workaround
if (app.router && Capacitor.getPlatform() === 'ios') { if (app.router && Capacitor.getPlatform() === 'ios') {

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