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
advplyrandGitHub 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
advplyrandGitHub 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
BenandGitHub 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
BenandGitHub 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
advplyrandGitHub 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
advplyrandGitHub 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
advplyrandGitHub 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
advplyrandGitHub 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
advplyrandGitHub 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 {
namespace 'com.audiobookshelf.app'
buildFeatures {
viewBinding true
}
@@ -33,8 +35,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 91
versionName "0.9.60-beta"
versionCode 92
versionName "0.9.61-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -78,6 +80,7 @@ configurations.all {
}
dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')
@@ -121,7 +124,7 @@ dependencies {
implementation 'io.github.pilgr:paperdb:2.7.2'
// Simple Storage
implementation "com.anggrayudi:storage:0.14.0"
implementation "com.anggrayudi:storage:1.5.4"
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
+3 -3
View File
@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
}
@@ -13,8 +13,8 @@ dependencies {
implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
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"
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app"
android:installLocation="preferExternal" >
<!-- Permissions -->
@@ -16,11 +16,11 @@
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
},
{
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
"pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
},
{
"pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
}
]
@@ -12,8 +12,8 @@ import android.util.Log
import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.AbsDatabase
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDownloader
@@ -51,11 +51,17 @@ class MainActivity : BridgeActivity() {
// .detectLeakedClosableObjects()
// .penaltyLog()
// .build())
DbManager.initialize(applicationContext)
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
DbManager.initialize(applicationContext)
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
@@ -63,11 +69,6 @@ class MainActivity : BridgeActivity() {
PERMISSIONS_ALL,
REQUEST_PERMISSIONS)
}
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
}
override fun onDestroy() {
@@ -52,7 +52,8 @@ class Podcast(
// Add new episodes
audioTracks.forEach { at ->
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)
}
}
@@ -65,7 +66,8 @@ class Podcast(
}
@JsonIgnore
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)
var index = 1
@@ -93,7 +95,8 @@ class Podcast(
@JsonIgnore
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)
var index = 1
@@ -249,7 +252,8 @@ data class PodcastEpisode(
var audioTrack:AudioTrack?,
var duration:Double?,
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
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
@@ -260,6 +264,14 @@ data class PodcastEpisode(
}
val extras = Bundle()
if (localEpisodeId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
@@ -283,8 +295,9 @@ data class PodcastEpisode(
}
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
val mediaId = localEpisodeId ?: id
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id)
.setMediaId(mediaId)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title)
@@ -357,26 +370,14 @@ data class BookChapter(
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)
@JsonSubTypes(
JsonSubTypes.Type(MediaProgress::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
data class LibraryItemWithEpisode(
@@ -2,6 +2,7 @@ package com.audiobookshelf.app.data
import android.content.Context
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
@@ -11,6 +12,14 @@ enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
}
enum class HapticFeedbackSetting {
OFF, LIGHT, MEDIUM, HEAVY
}
enum class ShakeSensitivitySetting {
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
}
data class ServerConnectionConfig(
var id:String,
var index:Int,
@@ -22,48 +31,6 @@ data class ServerConnectionConfig(
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)
data class LocalFile(
var id:String,
@@ -121,3 +88,80 @@ data class PlayItemRequestPayload(
var forceTranscode:Boolean,
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 media:MediaType,
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) {
@get:JsonIgnore
val title get() = media.metadata.title
@@ -59,6 +60,13 @@ class LibraryItem(
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
val extras = Bundle()
if (localLibraryItemId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
@@ -81,8 +89,9 @@ class LibraryItem(
)
}
val mediaId = localLibraryItemId ?: id
return MediaDescriptionCompat.Builder()
.setMediaId(id)
.setMediaId(mediaId)
.setTitle(title)
.setIconUri(getCoverUri())
.setSubtitle(authorName)
@@ -14,6 +14,7 @@ import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -117,6 +118,10 @@ class LocalLibraryItem(
}
val extras = Bundle()
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.data
import android.util.Log
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlin.math.roundToInt
@@ -11,7 +12,7 @@ class LocalMediaProgress(
var localEpisodeId:String?,
var duration:Double,
progress:Double, // 0 to 1
var currentTime:Double,
currentTime:Double,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
@@ -22,9 +23,16 @@ class LocalMediaProgress(
var serverUserId:String?,
var libraryItemId:String?,
var episodeId:String?
) : MediaProgressWrapper(isFinished, progress) {
) : MediaProgressWrapper(isFinished, currentTime, progress) {
@get:JsonIgnore
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
fun updateIsFinished(finished:Boolean) {
@@ -41,8 +49,7 @@ class LocalMediaProgress(
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
currentTime = playbackSession.currentTime
progress = playbackSession.progress
lastUpdate = System.currentTimeMillis()
lastUpdate = playbackSession.updatedAt
isFinished = playbackSession.progress >= 0.99
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.MediaQueueItem
import com.google.android.gms.common.images.WebImage
// TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1
val PLAYMETHOD_TRANSCODE = 2
val PLAYMETHOD_LOCAL = 3
import com.audiobookshelf.app.player.*
@JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession(
@@ -65,11 +59,13 @@ class PlaybackSession(
@get:JsonIgnore
val localLibraryItemId get() = localLibraryItem?.id ?: ""
@get:JsonIgnore
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore
val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@get:JsonIgnore
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
@JsonIgnore
fun getCurrentTrackIndex():Int {
@@ -82,12 +78,41 @@ class PlaybackSession(
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
fun getChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
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
fun getCurrentTrackTimeMs():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
@@ -2,6 +2,7 @@ package com.audiobookshelf.app.device
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
interface WidgetEventEmitter {
@@ -11,8 +12,8 @@ interface WidgetEventEmitter {
object DeviceManager {
const val tag = "DeviceManager"
val dbManager:DbManager = DbManager()
var deviceData:DeviceData = dbManager.getDeviceData()
val dbManager: DbManager = DbManager()
var deviceData: DeviceData = dbManager.getDeviceData()
var serverConnectionConfig: ServerConnectionConfig? = null
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
@@ -20,12 +21,21 @@ object DeviceManager {
val serverUserId get() = serverConnectionConfig?.userId ?: ""
val token get() = serverConnectionConfig?.token ?: ""
val isConnectedToServer get() = serverConnectionConfig != null
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
var widgetUpdater:WidgetEventEmitter? = null
init {
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 {
@@ -9,7 +9,7 @@ import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.Level
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.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
@@ -18,7 +18,7 @@ import org.json.JSONException
class FolderScanner(var ctx: Context) {
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?)
@@ -72,18 +72,18 @@ class FolderScanner(var ctx: Context) {
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 result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
else if (result == ItemScanResult.ADDED) mediaItemsAdded++
else mediaItemsUpToDate++
when (scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)) {
ItemScanResult.REMOVED -> mediaItemsRemoved++
ItemScanResult.UPDATED -> mediaItemsUpdated++
ItemScanResult.ADDED -> mediaItemsAdded++
else -> mediaItemsUpToDate++
}
}
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) {
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)
} else {
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 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}")
if (isAudio) {
var audioTrackToAdd:AudioTrack? = null
val audioTrackToAdd:AudioTrack?
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
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
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):DownloadItemScanResult? {
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
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 itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
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
itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx)
@@ -238,13 +240,13 @@ class FolderScanner(var ctx: Context) {
if (itemFolderUrl == "") {
Log.d(tag, "scanDownloadItem failed to find media folder")
return null
return cb(null)
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return null
return cb(null)
}
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
@@ -256,7 +258,7 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem? = null
var localLibraryItem:LocalLibraryItem?
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)
} else {
@@ -268,53 +270,50 @@ class FolderScanner(var ctx: Context) {
}
}
val audioTracks:MutableList<AudioTrack> = mutableListOf()
val audioTracks:MutableList<AudioTrack> = mutableListOf()
filesFound.forEach { docFile ->
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
itemPart.filename == docFile.name
}
if (itemPart == null) {
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
}
} else if (itemPart.audioTrack != null) { // Is audio track
val audioTrackFromServer = itemPart.audioTrack
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}")
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())
localLibraryItem.localFiles.add(localFile)
// TODO: Make asynchronous
val audioProbeResult = probeAudioFile(localFile.absolutePath)
// 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)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
}
} else { // Cover image
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())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
filesFound.forEach { docFile ->
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
itemPart.filename == docFile.name
}
if (itemPart == null) {
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
}
} else if (itemPart.audioTrack != null) { // Is audio track
val audioTrackFromServer = itemPart.audioTrack
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
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())
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
}
} else { // Cover image
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())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
if (audioTracks.isEmpty()) {
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
@@ -364,7 +363,7 @@ class FolderScanner(var ctx: Context) {
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return downloadItemScanResult
cb(downloadItemScanResult)
}
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
@@ -401,7 +400,7 @@ class FolderScanner(var ctx: Context) {
localLibraryItem.localFiles.removeIf { it.id == localFileId }
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)
}
wasUpdated = true
@@ -464,7 +463,7 @@ class FolderScanner(var ctx: Context) {
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")
var probeObject:JSObject? = null
@@ -475,13 +474,13 @@ class FolderScanner(var ctx: Context) {
}
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")
return null
null
} else {
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
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.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem
import io.paperdb.Paper
import java.io.File
@@ -10,7 +11,7 @@ class DbManager {
val tag = "DbManager"
companion object {
var isDbInitialized = false
private var isDbInitialized = false
fun initialize(ctx: Context) {
if (isDbInitialized) return
@@ -23,14 +24,14 @@ class DbManager {
fun getDeviceData(): DeviceData {
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
}
fun saveDeviceData(deviceData:DeviceData) {
fun saveDeviceData(deviceData: DeviceData) {
Paper.book("device").write("data", deviceData)
}
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
Paper.book("localLibraryItems").allKeys.forEach {
val localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
localLibraryItems.add(localLibraryItem)
}
@@ -45,16 +46,16 @@ class DbManager {
}
}
fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
}
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
return Paper.book("localLibraryItems").read(localLibraryItemId)
}
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
var podcastEpisode:PodcastEpisode? = null
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
var podcastEpisode: PodcastEpisode? = null
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
@@ -77,15 +78,15 @@ class DbManager {
}
}
fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) {
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
}
fun saveLocalFolder(localFolder:LocalFolder) {
fun saveLocalFolder(localFolder: LocalFolder) {
Paper.book("localFolders").write(localFolder.id,localFolder)
}
fun getLocalFolder(folderId:String):LocalFolder? {
fun getLocalFolder(folderId:String): LocalFolder? {
return Paper.book("localFolders").read(folderId)
}
@@ -107,7 +108,7 @@ class DbManager {
Paper.book("localFolders").delete(folderId)
}
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
fun saveDownloadItem(downloadItem: DownloadItem) {
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
}
@@ -115,21 +116,21 @@ class DbManager {
Paper.book("downloadItems").delete(downloadItemId)
}
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
fun getDownloadItems():List<DownloadItem> {
val downloadItems:MutableList<DownloadItem> = mutableListOf()
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
downloadItems.add(it)
}
}
return downloadItems
}
fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) {
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
}
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
fun getLocalMediaProgress(localMediaProgressId:String):LocalMediaProgress? {
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
return Paper.book("localMediaProgress").read(localMediaProgressId)
}
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
@@ -154,7 +155,7 @@ class DbManager {
val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli ->
var hasUpates = false
var hasUpdates = false
// Check local files
lli.localFiles = lli.localFiles.filter { localFile ->
@@ -162,7 +163,7 @@ class DbManager {
val file = File(localFile.absolutePath)
if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
hasUpates = true
hasUpdates = true
}
file.exists()
} as MutableList<LocalFile>
@@ -173,7 +174,7 @@ class DbManager {
podcast.episodes = podcast.episodes?.filter { ep ->
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}")
hasUpates = true
hasUpdates = true
}
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
} as MutableList<PodcastEpisode>
@@ -182,7 +183,7 @@ class DbManager {
book.tracks = book.tracks?.filter { track ->
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}")
hasUpates = true
hasUpdates = true
}
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
} as MutableList<AudioTrack>
@@ -196,11 +197,11 @@ class DbManager {
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
lli.coverAbsolutePath = null
lli.coverContentUrl = null
hasUpates = true
hasUpdates = true
}
}
if (hasUpates) {
if (hasUpdates) {
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
Paper.book("localLibraryItems").write(lli.id, lli)
}
@@ -236,10 +237,18 @@ class DbManager {
}
}
fun saveLocalPlaybackSession(playbackSession:PlaybackSession) {
fun saveLocalPlaybackSession(playbackSession: PlaybackSession) {
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
}
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
fun getLocalPlaybackSession(playbackSessionId:String): PlaybackSession? {
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.os.*
import android.util.Log
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 kotlin.concurrent.schedule
import kotlin.math.roundToInt
const val SLEEP_EXTENSION_TIME = 900000L // 15m
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
private val tag = "SleepTimerManager"
private var sleepTimerTask:TimerTask? = null
@@ -18,7 +19,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
private var sleepTimerEndTime:Long = 0L
private var sleepTimerLength:Long = 0L
private var sleepTimerElapsed:Long = 0L
private var sleepTimerExtensionTime:Long = 0L
private var sleepTimerFinishedAt:Long = 0L
private fun getCurrentTime():Long {
@@ -57,7 +57,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
sleepTimerTask?.cancel()
sleepTimerRunning = false
sleepTimerRunning = true
sleepTimerFinishedAt = 0L
sleepTimerElapsed = 0L
@@ -72,7 +72,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
}
sleepTimerEndTime = time
sleepTimerLength = 0
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
if (sleepTimerEndTime > getDuration()) {
sleepTimerEndTime = getDuration()
@@ -80,7 +79,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
} else {
sleepTimerLength = time
sleepTimerEndTime = 0L
sleepTimerExtensionTime = time
if (sleepTimerLength + getCurrentTime() > getDuration()) {
sleepTimerLength = getDuration() - getCurrentTime()
@@ -89,9 +87,8 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
sleepTimerRunning = true
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
if (getIsPlaying()) {
sleepTimerElapsed += 1000L
@@ -109,9 +106,9 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) {
} else if (sleepTimeSecondsRemaining <= 60) {
// Start fading out audio
val volume = sleepTimeSecondsRemaining / 30F
val volume = sleepTimeSecondsRemaining / 60F
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume)
}
@@ -121,7 +118,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
return true
}
fun clearSleepTimer() {
private fun clearSleepTimer() {
sleepTimerTask?.cancel()
sleepTimerTask = null
sleepTimerEndTime = 0
@@ -163,21 +160,40 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
}
}
private fun extendSleepTime() {
if (!sleepTimerRunning) return
setVolume(1F)
if (sleepTimerEndTime == 0L) {
sleepTimerLength += sleepTimerExtensionTime
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else {
sleepTimerEndTime += sleepTimerExtensionTime
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
// Get the chapter end time for use in End of Chapter timers
// if less than 2s remain in chapter then use the next chapter
private fun getChapterEndTime():Long? {
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
if (currentChapterEndTimeMs == null) {
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
return null
}
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
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 {
nextChapterEndTimeMs
}
} else {
currentChapterEndTimeMs
}
}
fun checkShouldExtendSleepTimer() {
private fun resetChapterTimer() {
this.getChapterEndTime()?.let { chapterEndTime ->
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
vibrate()
setSleepTimer(chapterEndTime, true)
play()
}
}
private fun checkShouldResetSleepTimer() {
if (!sleepTimerRunning) {
if (sleepTimerFinishedAt <= 0L) return
@@ -189,17 +205,35 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
return
}
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
vibrate()
setSleepTimer(newSleepTime, false)
play()
// 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()
setSleepTimer(sleepTimerLength, false)
play()
}
return
}
// Only extend if within 30 seconds of finishing
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
if (sleepTimeRemaining <= 30) {
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
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")
return
}
checkShouldExtendSleepTimer()
checkShouldResetSleepTimer()
}
}
@@ -254,4 +288,53 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
setVolume(1F)
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.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = ""
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
private var selectedLibraryItems = mutableListOf<LibraryItem>()
private var selectedLibraryId = ""
var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>()
private var selectedPodcast:Podcast? = null
private var selectedLibraryItemId:String? = null
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
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 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 {
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()) {
cb(serverItemsInProgress)
} 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")) {
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
} else {
@@ -139,6 +139,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
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 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)
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()) {
cb(serverLibraries)
} 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
suspendCoroutine<Boolean> { cont ->
suspendCoroutine { cont ->
apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it
@@ -190,9 +199,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return successfulPing
}
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
private suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
suspendCoroutine<MutableList<MediaProgress>> { cont ->
suspendCoroutine { cont ->
apiHandler.authorize(config) {
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
@@ -204,7 +213,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return mediaProgress
}
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
coroutineScope {
@@ -331,18 +340,18 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
return if (id.startsWith("local")) {
DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else {
return podcastEpisodeLibraryItemMap[id]
podcastEpisodeLibraryItemMap[id]
}
}
fun getById(id:String) : LibraryItemWrapper? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItem(id)
return if (id.startsWith("local")) {
DeviceManager.dbManager.getLocalLibraryItem(id)
} 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.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.data.Podcast
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import kotlin.concurrent.schedule
@@ -17,6 +19,12 @@ data class MediaProgressSyncData(
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) {
private val tag = "MediaProgressSync"
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 currentLocalMediaProgress: LocalMediaProgress? = null
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
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) {
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")
currentLocalMediaProgress = null
listeningTimerTask?.cancel()
@@ -48,25 +56,32 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
} else {
return
}
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
} else if (playbackSession.id != currentSessionId) {
currentLocalMediaProgress = null
}
listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis()
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() {
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 currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) {
sync(shouldSyncServer, currentTime) {
sync(shouldSyncServer, currentTime) { syncResult ->
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) {
if (!listeningTimerRunning) return
if (!listeningTimerRunning) {
reset()
return cb()
}
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
@@ -83,11 +109,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
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()
cb()
}
} else {
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.stopEvent(playbackSession, null)
}
reset()
cb()
}
@@ -95,6 +129,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
fun pause(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
@@ -103,51 +138,82 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) { // Current time should always be > 0 on pause
sync(true, currentTime) {
sync(true, currentTime) { syncResult ->
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, syncResult)
}
cb()
}
} else {
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, null)
}
cb()
}
}
fun finished(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
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()
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.finishedEvent(playbackSession, syncResult)
}
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) {
currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
}
}
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
if (lastSyncTime <= 0) {
Log.e(tag, "Last sync time is not set $lastSyncTime")
return
return cb(null)
}
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return cb()
return cb(null)
}
val listeningTimeToAdd = diffSinceLastSync / 1000L
@@ -157,7 +223,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
if (currentPlaybackSession?.progress?.isNaN() == true) {
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
return cb()
return cb(null)
}
if (currentIsLocal) {
@@ -170,11 +236,12 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
// 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
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
if (syncSuccess) {
failedSyncs = 0
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")
}
cb()
cb(SyncResult(true, syncSuccess, errorMsg))
}
} else {
cb()
cb(SyncResult(false, null, null))
}
}
} else if (shouldSyncServer) {
apiHandler.sendProgressSync(currentSessionId, syncData) {
if (it) {
Log.d(tag, "sync: currentSessionId=$currentSessionId")
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
if (syncSuccess) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0
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")
}
cb()
cb(SyncResult(true, syncSuccess, errorMsg))
}
} else {
cb()
cb(SyncResult(false, null, null))
}
}
@@ -222,17 +290,18 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else {
currentLocalMediaProgress = mediaProgress
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
} else {
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
currentLocalMediaProgress?.let {
if (it.progress.isNaN()) {
Log.e(tag, "Invalid progress on local media progress")
} else {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
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 com.audiobookshelf.app.R
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.ui.PlayerNotificationManager
import kotlinx.coroutines.*
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"
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
private var currentIconUri: Uri? = null
private var currentBitmap: Bitmap? = null
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@@ -48,6 +46,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
if (currentIconUri.toString().startsWith("content://")) {
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
} else {
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
* @param context - context
* @param drawableId - drawable res id
* @return - uri
*/
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
private fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(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())
}.build()
if (!itemsInProgress.isEmpty()) {
if (itemsInProgress.isNotEmpty()) {
rootList += continueListeningMetadata
}
@@ -2,10 +2,7 @@ package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.os.*
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import android.view.KeyEvent
@@ -18,7 +15,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
var tag = "MediaSessionCallback"
private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms
private var mediaButtonClickTimeout: Long = 1000 //ms
override fun onPrepare() {
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")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it,true, playbackRate)
}
}
@@ -54,7 +51,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
@@ -93,7 +90,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var libraryItemWrapper: LibraryItemWrapper? = null
val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null
if (mediaId.isNullOrEmpty()) {
@@ -116,7 +113,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
@@ -128,11 +125,17 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return handleCallMediaButton(mediaButtonEvent)
}
fun handleCallMediaButton(intent: Intent): Boolean {
private fun handleCallMediaButton(intent: Intent): Boolean {
Log.w(tag, "handleCallMediaButton $intent | ${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}")
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
@@ -166,7 +169,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
}
handleMediaButtonClickCount()
}
@@ -198,7 +200,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} else {
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
}
handleMediaButtonClickCount()
}
@@ -35,7 +35,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
@@ -46,7 +46,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
var libraryItemWrapper: LibraryItemWrapper? = null
val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null
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")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
@@ -79,7 +79,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
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_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
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
import android.util.Log
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.data.PlayerState
import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException
@@ -13,16 +14,101 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
companion object {
var lastPauseTime: Long = 0 //ms
var lazyIsPlaying: Boolean = false
}
private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) {
val errorMessage = error.message ?: "Unknown Error"
Log.e(tag, "onPlayerError $errorMessage")
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) {
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)
if (lastPauseTime == 0L) {
lastPauseTime = -1;
lastPauseTime = -1
}
playerNotificationService.sendClientMetadata(PlayerState.READY)
}
@@ -67,62 +153,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
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 {
@@ -31,9 +31,10 @@ import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo
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.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
@@ -49,8 +50,8 @@ import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
const val PLAYER_CAST = "cast-player";
const val PLAYER_EXO = "exo-player";
const val PLAYER_CAST = "cast-player"
const val PLAYER_EXO = "exo-player"
class PlayerNotificationService : MediaBrowserServiceCompat() {
@@ -61,6 +62,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
}
private val tag = "PlayerNotificationService"
interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession)
fun onPlaybackClosed()
@@ -74,9 +77,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun onProgressSyncFailing()
fun onProgressSyncSuccess()
fun onNetworkMeteredChanged(isUnmetered:Boolean)
fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory)
}
private val tag = "PlayerService"
private val binder = LocalBinder()
var clientEventEmitter:ClientEventEmitter? = null
@@ -101,7 +103,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private var channelId = "audiobookshelf_channel"
private var channelName = "Audiobookshelf Channel"
private var currentPlaybackSession:PlaybackSession? = null
var currentPlaybackSession:PlaybackSession? = null
private var initialPlaybackRate:Float? = null
private var isAndroidAuto = false
@@ -139,6 +141,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return START_STICKY
}
@Deprecated("Deprecated in Java")
override fun onStart(intent: Intent?, startId: Int) {
Log.d(tag, "onStart $startId")
}
@@ -227,7 +230,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
}
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@@ -392,7 +394,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
SkipForwardCustomActionProvider(),
))
}
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
playbackSession.mediaPlayer = getMediaPlayer()
@@ -638,14 +640,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.clone()
}
fun getCurrentPlaybackSessionId() :String? {
return currentPlaybackSession?.id
}
fun getCurrentBookChapter():BookChapter? {
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
// check with server if progress has updated since last play and sync progress update
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
@@ -682,7 +692,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
seekPlayer(playbackSession.currentTimeMs)
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
clientEventEmitter?.onPlayingUpdate(true)
}
} else {
@@ -690,9 +700,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (seekBackTime > 0L) {
seekBackward(seekBackTime)
}
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
clientEventEmitter?.onPlayingUpdate(true)
}
}
@@ -722,7 +733,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
mediaProgressSyncer.play(it)
clientEventEmitter?.onPlayingUpdate(true)
}
}
@@ -759,10 +770,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var timeToSeek = time
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek")
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
} 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
}
@@ -821,10 +832,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
currentPlaybackSession = null
mediaProgressSyncer.reset()
clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0
isClosed = true
stopForeground(true)
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -848,8 +860,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
}
@get:JsonIgnore
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
@@ -870,11 +881,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
//
// 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 AUTO_MEDIA_ROOT = "/"
@@ -939,6 +945,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
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)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
@@ -975,6 +983,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
// 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)
} else {
@@ -982,6 +998,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
} else {
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)
}
@@ -1032,7 +1051,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Shake sensor used for sleep timer
fun registerSensor() {
if (isShakeSensorRegistered) {
Log.w(tag, "Shake sensor already registered")
Log.i(tag, "Shake sensor already registered")
return
}
shakeSensorUnregisterTask?.cancel()
@@ -1052,7 +1071,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Unregister shake sensor after wake up expiration
shakeSensorUnregisterTask?.cancel()
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")
mSensorManager!!.unregisterListener(mShakeDetector)
isShakeSensorRegistered = false
@@ -4,6 +4,7 @@ import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.audiobookshelf.app.device.DeviceManager
import kotlin.math.sqrt
class ShakeDetector : SensorEventListener {
@@ -34,7 +35,8 @@ class ShakeDetector : SensorEventListener {
val gZ = z / SensorManager.GRAVITY_EARTH
// gForce will be close to 1 when there is no movement.
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()
// ignore shake events too close to each other (500ms)
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
* 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_COUNT_RESET_TIME_MS = 3000
}
@@ -6,7 +6,9 @@ import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.player.CastManager
import com.audiobookshelf.app.player.PlayerListener
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
@@ -21,7 +23,7 @@ import org.json.JSONObject
@CapacitorPlugin(name = "AbsAudioPlayer")
class AbsAudioPlayer : Plugin() {
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 apiHandler:ApiHandler
@@ -88,7 +90,13 @@ class AbsAudioPlayer : Plugin() {
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
emit("onNetworkMeteredChanged", isUnmetered)
}
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
}
})
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
}
mainActivity.pluginCallback = foregroundServiceReady
}
@@ -160,6 +168,8 @@ class AbsAudioPlayer : Plugin() {
val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
val startTimeOverride = call.getDouble("startTime")
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride")
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
@@ -181,10 +191,16 @@ class AbsAudioPlayer : Plugin() {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
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
playerNotificationService.mediaProgressSyncer.stop {
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
playerNotificationService.preparePlayer(
playbackSession,
@@ -194,6 +210,7 @@ class AbsAudioPlayer : Plugin() {
}
}
} else {
playerNotificationService.mediaProgressSyncer.reset()
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
}
}
@@ -201,28 +218,25 @@ class AbsAudioPlayer : Plugin() {
}
} else { // Play library item from server
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
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)
}
}
Handler(Looper.getMainLooper()).post {
playerNotificationService.mediaProgressSyncer.stop {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
it.currentTime = startTimeOverride
}
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
PlayerListener.lazyIsPlaying = false
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
}
@@ -267,6 +281,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod
fun seek(call: PluginCall) {
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
Log.d(tag, "seek action to $time")
Handler(Looper.getMainLooper()).post {
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
call.resolve()
@@ -1,8 +1,10 @@
package com.audiobookshelf.app.data
package com.audiobookshelf.app.plugins
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -16,7 +18,7 @@ import kotlinx.coroutines.launch
@CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() {
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 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
fun removeLocalMediaProgress(call:PluginCall) {
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
@@ -256,6 +276,8 @@ class AbsDatabase : Plugin() {
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
call.resolve()
} else {
MediaEventManager.syncEvent(mediaProgress, "Received from webhook event")
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
@@ -286,8 +308,8 @@ class AbsDatabase : Plugin() {
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
}
var duration = 0.0
var podcastEpisode:PodcastEpisode? = null
val duration: Double
var podcastEpisode: PodcastEpisode? = null
if (!localEpisodeId.isNullOrEmpty()) {
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { episode ->
@@ -406,4 +428,19 @@ class AbsDatabase : Plugin() {
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.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
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.data.*
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.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.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
@@ -23,108 +20,37 @@ import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
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
@CapacitorPlugin(name = "AbsDownloader")
class AbsDownloader : Plugin() {
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 downloadManager: DownloadManager
lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner
lateinit var downloadItemManager: DownloadItemManager
data class DownloadItemPart(
val id: String,
val filename: String,
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
)
}
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
override fun onDownloadItem(downloadItem:DownloadItem) {
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
}
@JsonIgnore
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
override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) {
notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart)))
}
}
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 onDownloadItemComplete(jsobj:JSObject) {
notifyListeners("onItemDownloadComplete", jsobj)
}
})
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
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")
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")
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?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
if (libraryItem.mediaType == "book") {
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemFolderPath = localFolder.absolutePath + "/" + 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 itemSubfolder = "$bookAuthor/$bookTitle"
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
tracks.forEach { audioTrack ->
val fileSize = audioTrack.metadata?.size ?: 0
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(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 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()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
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)
val dlRequest = downloadItemPart.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
if (downloadItem.downloadItemParts.isNotEmpty()) {
// Add cover download item
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 destinationFilename = "cover.jpg"
val destinationFilename = "cover-${libraryItem.id}.jpg"
val destinationFile = File("$tempFolderPath/$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()) {
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
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)
val dlRequest = downloadItemPart.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
downloadQueue.add(downloadItem)
startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem)
downloadItemManager.addDownloadItem(downloadItem)
}
} else {
// Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val audioTrack = episode?.audioTrack
val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download")
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
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 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 finalDestinationFile = File("$itemFolderPath/$destinationFilename")
@@ -281,14 +217,13 @@ class AbsDownloader : Plugin() {
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)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
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"
destinationFilename = "cover.jpg"
@@ -298,134 +233,12 @@ class AbsDownloader : Plugin() {
if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again")
} 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)
dlRequest = downloadItemPart.getDownloadRequest()
downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
}
downloadQueue.add(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)
}
}
}
downloadItemManager.addDownloadItem(downloadItem)
}
}
}
@@ -28,7 +28,7 @@ import kotlinx.coroutines.launch
class AbsFileSystem : Plugin() {
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
@@ -85,21 +85,28 @@ class AbsFileSystem : Plugin() {
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()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(
"You have no write access to this storage, thus selecting this folder is useless." +
"\nWould you like to grant access to this folder?")
"\nWould you like to grant access to this folder?")
builder.setNegativeButton("Dont Allow") { _, _ ->
run {
jsobj.put("error", "User Canceled, Access Denied")
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()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
@@ -131,7 +138,7 @@ class AbsFileSystem : Plugin() {
@PluginMethod
fun checkStoragePermission(call: PluginCall) {
var res: Boolean
val res: Boolean
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
res = SimpleStorage.hasStoragePermission(context)
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
@@ -140,33 +147,33 @@ class AbsFileSystem : Plugin() {
res = true
}
var jsobj = JSObject()
val jsobj = JSObject()
jsobj.put("value", res)
call.resolve(jsobj)
}
@PluginMethod
fun checkFolderPermissions(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
val folderUrl = call.data.getString("folderUrl", "").toString()
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)
call.resolve(jsobj)
}
@PluginMethod
fun scanFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
val folderId = call.data.getString("folderId", "").toString()
val forceAudioProbe = call.data.getBoolean("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 {
var folderScanner = FolderScanner(context)
var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
val folderScanner = FolderScanner(context)
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
if (folderScanResult == null) {
Log.d(TAG, "NO Scan DATA")
return call.resolve(JSObject())
@@ -179,28 +186,28 @@ class AbsFileSystem : Plugin() {
@PluginMethod
fun removeFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
val folderId = call.data.getString("folderId", "").toString()
DeviceManager.dbManager.removeLocalFolder(folderId)
call.resolve()
}
@PluginMethod
fun removeLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
call.resolve()
}
@PluginMethod
fun scanLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
localLibraryItem?.let {
var folderScanner = FolderScanner(context)
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
val folderScanner = FolderScanner(context)
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
if (scanResult == null) {
Log.d(TAG, "NO Scan DATA")
call.resolve(JSObject())
@@ -214,13 +221,13 @@ class AbsFileSystem : Plugin() {
@PluginMethod
fun deleteItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString()
var absolutePath = call.data.getString("absolutePath", "").toString()
var contentUrl = call.data.getString("contentUrl", "").toString()
val localLibraryItemId = call.data.getString("id", "").toString()
val absolutePath = call.data.getString("absolutePath", "").toString()
val contentUrl = call.data.getString("contentUrl", "").toString()
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
val success = docfile?.delete() == true
if (success) {
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
}
@@ -229,19 +236,19 @@ class AbsFileSystem : Plugin() {
@PluginMethod
fun deleteTrackFromItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString()
var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
var contentUrl = call.data.getString("trackContentUrl", "").toString()
val localLibraryItemId = call.data.getString("id", "").toString()
val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
val contentUrl = call.data.getString("trackContentUrl", "").toString()
Log.d(tag, "deleteTrackFromItem $contentUrl")
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
return call.resolve(JSObject("{\"success\":false}"))
}
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
val success = docfile?.delete() == true
if (success) {
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
localLibraryItem.removeLocalFile(trackLocalFileId)
@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
@@ -31,8 +32,8 @@ class ApiHandler(var ctx:Context) {
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
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, var serverProgressUpdates:List<MediaProgress>)
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
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))
postRequest("/api/session/$sessionId/sync", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
cb(false, it.getString("error"))
} 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))
postRequest("/api/session/local", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
cb(false, it.getString("error"))
} else {
cb(true)
cb(true, null)
}
}
}
@@ -253,7 +254,7 @@ class ApiHandler(var ctx:Context) {
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
if (!isOnline()) {
Log.d(tag, "Error not online")
cb(LocalMediaProgressSyncResultsPayload(0,0,0))
cb(LocalMediaProgressSyncResultsPayload(0,0,0, mutableListOf()))
return
}
@@ -262,7 +263,7 @@ class ApiHandler(var ctx:Context) {
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
}
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0, mutableListOf())
if (localMediaProgress.isNotEmpty()) {
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())
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
localSyncResultsPayload.serverProgressUpdates = progressSyncResponsePayload.serverProgressUpdates
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
// Update all local media progress
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
MediaEventManager.syncEvent(localMediaProgress, "Local progress updated. Received from server sync local API request")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
}
}
progressSyncResponsePayload.serverProgressUpdates.forEach { localMediaProgress ->
MediaEventManager.syncEvent(localMediaProgress, "Server progress updated. Received from server sync local API request")
}
}
cb(localSyncResultsPayload)
+2 -2
View File
@@ -9,7 +9,7 @@
<item name="colorAccent">@color/colorAccent</item>
</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="windowNoTitle">true</item>
<item name="android:background">@null</item>
@@ -18,7 +18,7 @@
</style>
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<!-- <item name="android:background">@drawable/screen</item>-->
</style>
<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.
buildscript {
ext.kotlin_version = '1.5.30'
ext.kotlin_version = '1.7.20'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.android.tools.build:gradle:7.2.2'
classpath 'com.google.gms:google-services:4.3.13'
classpath 'com.android.tools.build:gradle:7.4.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
@@ -29,3 +29,4 @@ allprojects {
task clean(type: Delete) {
delete rootProject.buildDir
}
+3 -3
View File
@@ -14,8 +14,8 @@ project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-network'
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'
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
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
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+12 -10
View File
@@ -1,16 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 31
targetSdkVersion = 31
androidxActivityVersion = '1.2.0'
androidxAppCompatVersion = '1.4.1'
compileSdkVersion = 33
targetSdkVersion = 32
androidxActivityVersion = '1.4.0'
androidxAppCompatVersion = '1.4.2'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.6.0'
androidxCoreVersion = '1.8.0'
androidPlayCore = '1.9.0'
androidxFragmentVersion = '1.3.0'
junitVersion = '4.13.1'
androidxJunitVersion = '1.1.2'
androidxEspressoCoreVersion = '3.3.0'
androidxFragmentVersion = '1.4.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.3'
androidxEspressoCoreVersion = '3.4.0'
cordovaAndroidVersion = '10.1.1'
androidx_car_version = '1.0.0-alpha7'
androidx_core_ktx_version = '1.7.0'
@@ -27,11 +27,13 @@ ext {
gradle_version = '3.1.4'
gson_version = '2.8.5'
junit_version = '4.13'
kotlin_version = '1.5.30'
kotlin_version = '1.7.20'
kotlin_coroutines_version = '1.1.0'
multidex_version = '1.0.3'
play_services_auth_version = '18.1.0'
recycler_view_version = '1.1.0'
robolectric_version = '4.2'
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-style: normal;
font-weight: 400;
src: url(/fonts/MaterialIcons.woff2) format('woff2');
src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype');
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-weight: 400;
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
}
.material-icons {
+2 -2
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-10 flex items-center px-2">
<div class="w-full h-16 bg-primary relative z-20">
<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">
<img src="/Logo.png" class="h-10 w-10" />
</nuxt-link>
+153 -86
View File
@@ -1,19 +1,21 @@
<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="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
<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 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">
<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 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 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>
<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 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">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
<div class="flex-grow" />
@@ -29,8 +31,8 @@
</div>
<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">
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="w-full h-full flex justify-center">
<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 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 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="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
<p class="title-text truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
</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 v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
<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 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">
<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 -->
@@ -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>
</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 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="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<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">
<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" />
</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>
@@ -76,6 +81,13 @@
</div>
<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="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" />
@@ -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="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 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>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
<template v-slot:chapter_track="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
<template v-slot:lock="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
</modals-dialog>
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor'
import { FastAverageColor } from 'fast-average-color'
export default {
props: {
@@ -132,6 +121,7 @@ export default {
data() {
return {
windowHeight: 0,
windowWidth: 0,
playbackSession: null,
showChapterModal: false,
showFullscreen: false,
@@ -157,39 +147,62 @@ export default {
touchStartTime: 0,
touchEndY: 0,
useChapterTrack: false,
useTotalTrack: true,
lockUi: false,
isLoading: false,
touchTrackStart: false,
dragPercent: 0,
syncStatus: 0,
showMoreMenuDialog: false
showMoreMenuDialog: false,
coverRgb: 'rgb(55, 56, 56)',
coverBgIsLight: false
}
},
watch: {
showFullscreen(val) {
this.updateScreenSize()
this.$store.commit('setPlayerFullscreen', !!val)
},
bookCoverAspectRatio() {
this.updateScreenSize()
}
},
computed: {
menuItems() {
var items = [
{
text: 'Chapter Track',
value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
value: 'lock',
icon: this.lockUi ? 'lock' : 'lock_open'
},
{
text: 'Close Player',
value: 'close',
icon: 'close'
}
]
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',
value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
value: 'lock',
icon: this.lockUi ? 'lock' : 'lock_open'
},
{
text: 'Close Player',
value: 'close',
icon: 'close'
}
]
)
return items
},
@@ -213,11 +226,26 @@ export default {
return 46 / this.bookCoverAspectRatio
},
fullscreenBookCoverWidth() {
var heightScale = (this.windowHeight - 200) / 651
if (this.bookCoverAspectRatio === 1) {
return 260 * heightScale
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) {
return 260 * heightScale
}
return 190 * heightScale
}
return 200 * heightScale
},
showCastBtn() {
return this.$store.state.isCastAvailable
@@ -328,9 +356,33 @@ export default {
},
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: {
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() {
if (!this.showFullscreen) return
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
@@ -340,17 +392,17 @@ export default {
}
},
async touchstartTrack(e) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true
},
async selectChapter(chapter) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.seek(chapter.start)
this.showChapterModal = false
},
async castClick() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isLocalPlayMethod) {
this.$eventBus.$emit('cast-local-item')
return
@@ -370,13 +422,13 @@ export default {
this.forceCloseDropdownMenu()
},
async jumpNextChapter() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isLoading) return
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
async jumpChapterStart() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isLoading) return
if (!this.currentChapter) {
return this.restart()
@@ -397,7 +449,7 @@ export default {
this.$emit('showSleepTimer')
},
async setPlaybackSpeed(speed) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
@@ -406,12 +458,12 @@ export default {
this.seek(0)
},
async jumpBackwards() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
},
async jumpForward() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
},
@@ -551,7 +603,7 @@ export default {
this.seek(time)
},
async playPauseClick() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isLoading) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
@@ -655,19 +707,31 @@ export default {
}
},
async clickMenuAction(action) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.showMoreMenuDialog = false
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.$localStore.setPlayerLock(this.lockUi)
} else if (action === 'chapter_track') {
this.useChapterTrack = !this.useChapterTrack
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
this.updateTimestamp()
this.updateTrack()
this.updateReadyTrack()
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') {
this.closePlayback()
}
@@ -775,9 +839,14 @@ export default {
},
updateScreenSize() {
this.windowHeight = window.innerHeight
this.windowWidth = window.innerWidth
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-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() {
this.showFullscreen = false
@@ -843,10 +912,9 @@ export default {
--cover-image-height: 0px;
--cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 46px;
--title-author-left-offset-collapsed: 70px;
}
.bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066;
}
.playerContainer {
height: 100px;
}
@@ -856,19 +924,18 @@ export default {
#playerContent {
box-shadow: 0px -8px 8px #11111155;
}
.fullscreen #playerContent {
box-shadow: none;
}
#playerTrack {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin;
bottom: 10px;
bottom: 20px;
}
.fullscreen #playerTrack {
bottom: 32px;
}
.ios-player #timestamp-row {
padding-left: 16px;
padding-right: 16px;
top: 20px;
bottom: unset;
}
.cover-wrapper {
@@ -894,7 +961,7 @@ export default {
width: 40%;
bottom: 56px;
left: 70px;
left: var(--title-author-left-offset-collapsed);
text-align: left;
}
.title-author-texts .title-text {
@@ -919,10 +986,10 @@ export default {
pointer-events: auto;
}
.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 {
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 {
@@ -969,7 +1036,7 @@ export default {
.fullscreen #playerControls {
width: 100%;
bottom: 105px;
bottom: 94px;
}
.fullscreen #playerControls .jump-icon {
margin: 0px 18px;
+16 -3
View File
@@ -184,6 +184,7 @@ export default {
async playLibraryItem(payload) {
const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId
const startTime = payload.startTime
// 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
@@ -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.serverEpisodeId = null
@@ -209,7 +220,9 @@ export default {
}
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) => {
if (data.error) {
const errorMsg = data.error || 'Failed to play'
@@ -273,7 +286,7 @@ export default {
this.$eventBus.$on('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
this.$eventBus.$on('user-settings', this.settingsUpdated)
},
beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
@@ -286,7 +299,7 @@ export default {
this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$store.commit('user/removeSettingsListener', 'streamContainer')
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
}
</script>
+5 -5
View File
@@ -115,10 +115,10 @@ export default {
})
} else {
items.push({
icon: 'download',
iconOutlined: false,
text: 'Downloads',
to: '/downloads'
icon: 'download',
iconOutlined: false,
text: 'Downloads',
to: '/downloads'
})
}
items.push({
@@ -137,7 +137,7 @@ export default {
this.show = false
},
async logout() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.user) {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
+16 -10
View File
@@ -309,7 +309,7 @@ export default {
var { clientHeight, clientWidth } = bookshelf
this.bookshelfHeight = clientHeight
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.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
@@ -371,15 +371,16 @@ export default {
return searchParams.toString()
},
checkUpdateSearchParams() {
var newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search
const newSearchParams = this.buildSearchParams()
let currentQueryString = window.location.search
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
if (newSearchParams === '') {
if (newSearchParams === '' && !currentQueryString) {
return false
}
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)
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
@@ -389,12 +390,17 @@ export default {
return false
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
const wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
}
},
libraryChanged() {
if (this.currentLibraryMediaType !== 'book' && (this.page === 'series' || this.page === 'collections' || this.page === 'series-books')) {
this.$router.replace('/bookshelf')
return
}
if (this.hasFilter) {
this.clearFilter()
} else {
@@ -447,13 +453,13 @@ export default {
})
},
initListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
const bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.addEventListener('scroll', this.scroll)
}
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_added', this.libraryItemAdded)
@@ -462,13 +468,13 @@ export default {
this.$socket.$on('items_added', this.libraryItemsAdded)
},
removeListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
const bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
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_added', this.libraryItemAdded)
+4 -4
View File
@@ -407,7 +407,7 @@ export default {
e.preventDefault()
this.selectBtnClick()
} else if (this.recentEpisode) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) {
eventBus.$emit('pause-item')
@@ -526,9 +526,9 @@ export default {
this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
const { naturalWidth, naturalHeight } = this.$refs.cover
const aspectRatio = naturalHeight / naturalWidth
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 (arDiff > 0.15) {
+30 -6
View File
@@ -1,18 +1,18 @@
<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 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 -->
<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>
<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 }" />
</div>
<!-- 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 class="flex-grow px-2">
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
@@ -76,6 +76,9 @@ export default {
this.libraryItem = newVal
}
}
},
bookCoverAspectRatio() {
this.setCSSProperties()
}
},
computed: {
@@ -313,6 +316,9 @@ export default {
if (!this.isAlternativeBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
},
coverWidth() {
return 80 / this.bookCoverAspectRatio
}
},
methods: {
@@ -433,9 +439,9 @@ export default {
this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
const { naturalWidth, naturalHeight } = this.$refs.cover
const aspectRatio = naturalHeight / naturalWidth
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 (arDiff > 0.15) {
@@ -445,9 +451,13 @@ export default {
this.showCoverBg = false
}
}
},
setCSSProperties() {
document.documentElement.style.setProperty('--list-card-cover-width', this.coverWidth + 'px')
}
},
mounted() {
this.setCSSProperties()
if (this.bookMount) {
this.setEntity(this.bookMount)
@@ -458,3 +468,17 @@ export default {
}
}
</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() {
return this.series ? this.series.id : null
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
return !!validCovers.length
}
},
methods: {
+3 -3
View File
@@ -132,7 +132,7 @@ export default {
}
},
async connectToServer(config) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
console.log('[ServerConnectForm] connectToServer', config.address)
this.processing = true
this.serverConfig = {
@@ -160,7 +160,7 @@ export default {
},
async removeServerConfigClick() {
if (!this.serverConfig.id) return
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
const { value } = await Dialog.confirm({
title: 'Confirm',
@@ -192,7 +192,7 @@ export default {
this.showAuth = true
},
async newServerConfigClick() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.serverConfig = {
address: '',
userId: '',
+2
View File
@@ -169,6 +169,8 @@ export default {
this.showCoverBg = false
}
}
this.$emit('imageLoaded', this.fullCoverUrl)
},
imageError(err) {
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()
},
saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings) // TODO: No need to update settings on server...
this.$store.dispatch('user/updateUserSettings', this.settings)
},
async init() {
this.bookshelfListView = await this.$localStore.getBookshelfListView()
@@ -105,17 +104,17 @@ export default {
},
async changeView() {
this.bookshelfListView = !this.bookshelfListView
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
}
},
mounted() {
this.init()
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() {
this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities)
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
}
</script>
+3 -3
View File
@@ -95,7 +95,7 @@ export default {
this.showBookmarkTitleInput = true
},
async deleteBookmark(bm) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
const { value } = await Dialog.confirm({
title: 'Remove Bookmark',
message: `Are you sure you want to remove bookmark?`
@@ -114,7 +114,7 @@ export default {
this.show = false
},
async clickBookmark(bm) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.$emit('select', bm)
},
submitUpdateBookmark(updatedBookmark) {
@@ -159,7 +159,7 @@ export default {
this.showBookmarkTitleInput = true
},
async submitBookmark() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.selectedBookmark) {
var updatePayload = {
...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 ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="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)">
<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">
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
</div>
+1
View File
@@ -13,6 +13,7 @@
<slot :name="item.value" :item="item" :selected="item.value === selected">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3">
<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>
</div>
</li>
+2 -2
View File
@@ -213,7 +213,7 @@ export default {
},
methods: {
async clearSelected() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.selected = 'all'
this.show = false
this.$nextTick(() => this.$emit('change', 'all'))
@@ -232,7 +232,7 @@ export default {
this.show = false
return
}
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.selected = val
this.show = false
this.$nextTick(() => this.$emit('change', val))
+1 -1
View File
@@ -49,7 +49,7 @@ export default {
},
methods: {
async clickedOption(lib) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.show = false
if (lib.id === this.currentLibraryId) return
await this.$store.dispatch('libraries/fetch', lib.id)
+1 -1
View File
@@ -127,7 +127,7 @@ export default {
},
methods: {
async clickedOption(val) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.selected === val) {
this.selectedDesc = !this.selectedDesc
} else {
+2 -2
View File
@@ -43,7 +43,7 @@ export default {
return {
currentPlaybackRate: 0,
MIN_SPEED: 0.5,
MAX_SPEED: 3
MAX_SPEED: 10
}
},
watch: {
@@ -71,7 +71,7 @@ export default {
}
},
rates() {
return [0.5, 1, 1.2, 1.5, 2]
return [0.5, 1, 1.2, 1.5, 2, 3]
},
canIncrement() {
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 class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Manual sleep timer</span>
<span class="font-normal block truncate text-lg text-center">Custom time</span>
</div>
</li>
</ul>
@@ -90,36 +90,36 @@ export default {
},
methods: {
async clickedChapterOption() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.show = false
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
},
async clickedOption(timeoutMin) {
await this.$hapticsImpactMedium()
var timeout = timeoutMin * 1000 * 60
await this.$hapticsImpact()
const timeout = timeoutMin * 1000 * 60
this.show = false
this.manualTimerModal = false
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
},
async cancelSleepTimer() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.$emit('cancel')
this.show = false
},
async increaseSleepTime() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.$emit('increase')
},
async decreaseSleepTime() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.$emit('decrease')
},
async increaseManualTimeout() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.manualTimeoutMin++
},
async decreaseManualTimeout() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
}
},
@@ -115,7 +115,7 @@ export default {
})
},
async clickPlaylist(playlist) {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (playlist.isItemIncluded) {
this.removeFromPlaylist(playlist)
} else {
@@ -165,7 +165,7 @@ export default {
this.showPlaylistNameInput = true
},
async submitCreatePlaylist() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {
return
}
+3 -3
View File
@@ -187,7 +187,7 @@ export default {
},
async downloadClick() {
if (this.downloadItem) return
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isIos) {
// no local folders on iOS
this.startDownload()
@@ -248,7 +248,7 @@ export default {
}
},
async playClick() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
@@ -270,7 +270,7 @@ export default {
}
},
async toggleFinished() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) {
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 : ''
},
buttonClass() {
var classes = []
const classes = []
if (this.small) classes.push('h-9')
else classes.push('h-10')
+24 -3
View File
@@ -1,9 +1,15 @@
<template>
<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">
<span class="material-icons text-lg">{{ prependIcon }}</span>
</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>
</template>
@@ -14,6 +20,7 @@ export default {
placeholder: String,
type: String,
disabled: Boolean,
readonly: Boolean,
borderless: Boolean,
bg: {
type: String,
@@ -26,7 +33,12 @@ export default {
prependIcon: {
type: String,
default: null
}
},
appendIcon: {
type: String,
default: null
},
clearable: Boolean
},
data() {
return {}
@@ -53,6 +65,9 @@ export default {
}
},
methods: {
clear() {
this.input = ''
},
focus() {
if (this.$refs.input) {
this.$refs.input.focus()
@@ -67,4 +82,10 @@ export default {
},
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: {},
methods: {
updateProgress() {
var progbar = this.$refs.progressbar
var circle = this.$refs.circle
const progbar = this.$refs.progressbar
const circle = this.$refs.circle
if (!progbar || !circle) return
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', progress)
@@ -1,6 +1,6 @@
<template>
<div v-if="numPartsRemaining > 0">
<widgets-circle-progress :value="progress" :count="numPartsRemaining" />
<div v-if="downloadItemPartsRemaining.length" @click="clickedIt">
<widgets-circle-progress :value="progress" :count="downloadItemPartsRemaining.length" />
</div>
</template>
@@ -10,73 +10,42 @@ import { AbsDownloader } from '@/plugins/capacitor'
export default {
data() {
return {
updateListener: null,
downloadItemListener: null,
completeListener: null,
itemDownloadingMap: {}
itemPartUpdateListener: null
}
},
computed: {
numItemPartsComplete() {
var total = 0
Object.values(this.itemDownloadingMap).map((item) => (total += item.partsCompleted))
return total
downloadItems() {
return this.$store.state.globals.itemDownloads
},
numPartsRemaining() {
return this.numTotalParts - this.numItemPartsComplete
downloadItemParts() {
let parts = []
this.downloadItems.forEach((di) => parts.push(...di.downloadItemParts))
return parts
},
numTotalParts() {
var total = 0
Object.values(this.itemDownloadingMap).map((item) => (total += item.totalParts))
return total
downloadItemPartsRemaining() {
return this.downloadItemParts.filter((dip) => !dip.completed)
},
progress() {
var numItems = Object.keys(this.itemDownloadingMap).length
if (!numItems) return 0
var totalProg = 0
Object.values(this.itemDownloadingMap).map((item) => (totalProg += item.itemProgress))
return totalProg / numItems
let totalBytes = 0
let totalBytesDownloaded = 0
this.downloadItemParts.forEach((dip) => {
totalBytes += dip.fileSize
totalBytesDownloaded += dip.bytesDownloaded
})
if (!totalBytes) return 0
return Math.min(1, totalBytesDownloaded / totalBytes)
},
isIos() {
return this.$platform === 'ios'
}
},
methods: {
onItemDownloadUpdate(data) {
console.log('DownloadProgressIndicator onItemDownloadUpdate', JSON.stringify(data))
if (!data || !data.downloadItemParts) {
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)
clickedIt() {
if (this.isIos) return // TODO: Implement on iOS
this.$router.push('/downloading')
},
onItemDownloadComplete(data) {
console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data))
@@ -85,11 +54,6 @@ export default {
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) {
this.$toast.error('Item download complete but failed to create library item')
} else {
@@ -103,15 +67,28 @@ export default {
}
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() {
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))
},
beforeDestroy() {
if (this.updateListener) this.updateListener.remove()
if (this.downloadItemListener) this.downloadItemListener.remove()
if (this.completeListener) this.completeListener.remove()
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove()
}
}
</script>
+8 -8
View File
@@ -651,7 +651,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -705,7 +705,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -720,12 +720,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
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\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -744,12 +744,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
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_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -768,7 +768,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@@ -790,7 +790,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
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.
let configuration = Realm.Configuration(
schemaVersion: 5,
schemaVersion: 6,
migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
@@ -36,6 +36,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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
@@ -88,15 +94,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>INPlayMediaIntent</string>
+2
View File
@@ -237,12 +237,14 @@ public class AbsDatabase: CAPPlugin {
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
let settings = DeviceSettings()
settings.disableAutoRewind = disableAutoRewind
settings.enableAltView = enableAltView
settings.jumpBackwardsTime = jumpBackwardsTime
settings.jumpForwardTime = jumpForwardTime
settings.lockOrientation = lockOrientation
settings.hapticFeedback = hapticFeedback
Database.shared.setDeviceSettings(deviceSettings: settings)
+10 -3
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!
# workaround to avoid Xcode caching of Pods that requires
@@ -13,8 +15,8 @@ def capacitor_pods
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
end
@@ -22,6 +24,11 @@ target 'Audiobookshelf' do
capacitor_pods
# Add your Pods here
pod 'RealmSwift', '~>10'
pod 'RealmSwift', '~> 10'
pod 'Alamofire', '~> 5.5'
end
post_install do |installer|
assertDeploymentTarget(installer)
end
+4 -2
View File
@@ -10,10 +10,11 @@ import RealmSwift
class DeviceSettings: Object {
@Persisted var disableAutoRewind: Bool = false
@Persisted var enableAltView: Bool = false
@Persisted var enableAltView: Bool = true
@Persisted var jumpBackwardsTime: Int = 10
@Persisted var jumpForwardTime: Int = 10
@Persisted var lockOrientation: String = "NONE"
@Persisted var hapticFeedback: String = "LIGHT"
}
func getDefaultDeviceSettings() -> DeviceSettings {
@@ -26,6 +27,7 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
"enableAltView": settings.enableAltView,
"jumpBackwardsTime": settings.jumpBackwardsTime,
"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 timeObserverToken: Any?
private var sleepTimerObserverToken: Any?
private var queueObserver:NSKeyValueObservation?
private var queueItemStatusObserver:NSKeyValueObservation?
@@ -96,7 +97,7 @@ class AudioPlayer: NSObject {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
setupTimeObserver()
setupTimeObservers()
setupQueueObserver()
setupQueueItemStatusObserver()
@@ -130,7 +131,7 @@ class AudioPlayer: NSObject {
// Remove observers
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), 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)
@@ -170,15 +171,15 @@ class AudioPlayer: NSObject {
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
DispatchQueue.runOnMainQueue {
self.removeTimeObserver()
self.removeTimeObservers()
let timeScale = CMTimeScale(NSEC_PER_SEC)
// Rate will be different depending on playback speed, aim for 2 observations/sec
let seconds = 0.5 * (self.rate > 0 ? self.rate : 1.0)
let time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
// Save the current time every 15 seconds
var seconds = 15.0
var time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
guard let self = self else { return }
guard self.isInitialized() else { return }
@@ -190,7 +191,12 @@ class AudioPlayer: NSObject {
// Let the player update the current playback positions
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() {
// Update the UI
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 {
self.audioPlayer.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
if let sleepTimerObserverToken = sleepTimerObserverToken {
self.audioPlayer.removeTimeObserver(sleepTimerObserverToken)
self.sleepTimerObserverToken = nil
}
}
private func setupQueueObserver() {
@@ -426,7 +436,7 @@ class AudioPlayer: NSObject {
self.tmpRate = 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) {
// The only reason this is separate is because the filename needs to be encoded
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let relPath = track.metadata?.relPath ?? ""
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file
logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let relPath = track.metadata?.relPath ?? ""
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
}
@@ -561,6 +571,8 @@ class AudioPlayer: NSObject {
}
let commandCenter = MPRemoteCommandCenter.shared()
let deviceSettings = Database.shared.getDeviceSettings()
let jumpForwardTime = deviceSettings.jumpForwardTime
let jumpBackwardsTime = deviceSettings.jumpBackwardsTime
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] event in
@@ -574,7 +586,7 @@ class AudioPlayer: NSObject {
}
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)]
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: jumpForwardTime)]
commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent
@@ -586,7 +598,7 @@ class AudioPlayer: NSObject {
return .success
}
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)]
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: jumpBackwardsTime)]
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent
@@ -598,6 +610,23 @@ class AudioPlayer: NSObject {
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.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
+6 -1
View File
@@ -12,7 +12,7 @@ import RealmSwift
class 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")
@@ -117,6 +117,11 @@ class PlayerProgress {
}
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
guard var session = session.thaw() else { return }
+16
View File
@@ -243,12 +243,28 @@ class ApiClient {
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 {
var localMediaProgress: [LocalMediaProgress]
}
struct PingResponsePayload: Codable {
var success: Bool
}
struct MediaProgressSyncResponsePayload: Decodable {
var numServerProgressUpdates: Int?
var localProgressUpdates: [LocalMediaProgress]?
+39 -21
View File
@@ -76,15 +76,12 @@ export default {
}
},
async loadSavedSettings() {
var userSavedServerSettings = await this.$localStore.getServerSettings()
const userSavedServerSettings = await this.$localStore.getServerSettings()
if (userSavedServerSettings) {
this.$store.commit('setServerSettings', userSavedServerSettings)
}
var userSavedSettings = await this.$localStore.getUserSettings()
if (userSavedSettings) {
this.$store.commit('user/setSettings', userSavedSettings)
}
await this.$store.dispatch('user/loadUserSettings')
},
async attemptConnection() {
console.warn('[default] attemptConnection')
@@ -97,11 +94,16 @@ export default {
}
this.attemptingConnection = true
var deviceData = await this.$db.getDeviceData()
var serverConfig = null
if (deviceData && deviceData.lastServerConnectionConfigId && deviceData.serverConnectionConfigs.length) {
serverConfig = deviceData.serverConnectionConfigs.find((scc) => scc.id == deviceData.lastServerConnectionConfigId)
const deviceData = await this.$db.getDeviceData()
let serverConfig = null
if (deviceData) {
this.$store.commit('globals/setHapticFeedback', deviceData.hapticFeedback)
if (deviceData.lastServerConnectionConfigId && deviceData.serverConnectionConfigs.length) {
serverConfig = deviceData.serverConnectionConfigs.find((scc) => scc.id == deviceData.lastServerConnectionConfigId)
}
}
if (!serverConfig) {
// No last server config set
this.attemptingConnection = false
@@ -110,9 +112,9 @@ export default {
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)
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
return false
})
@@ -125,13 +127,13 @@ export default {
this.$store.commit('setServerSettings', serverSettings)
// 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))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (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/setServerConnectionConfig', serverConnectionConfig)
@@ -181,12 +183,19 @@ export default {
this.$store.commit('setLastLocalMediaSyncResults', null)
return
}
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates, serverProgressUpdates } = response
if (numLocalMediaProgressForServer > 0) {
response.syncedAt = Date.now()
response.serverConfigName = this.$store.getters['user/getServerConfigName']
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) {
console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`)
} else {
@@ -197,7 +206,8 @@ export default {
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) {
this.$store.commit('user/setUser', user)
}
@@ -206,11 +216,19 @@ export default {
console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`)
// Update local media progress if exists
var localProg = this.$store.getters['globals/getLocalMediaProgressByServerItemId'](prog.libraryItemId, prog.episodeId)
var newLocalMediaProgress = null
const localProg = await this.$db.getLocalMediaProgressForServerItem({ libraryItemId: prog.libraryItemId, episodeId: prog.episodeId })
let newLocalMediaProgress = null
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
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 = {
localMediaProgressId: localProg.id,
mediaProgress: prog
@@ -219,12 +237,12 @@ export default {
} else if (!localProg) {
// Check if local library item exists
// 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 (prog.episodeId) {
// If episode check if local episode exists
var lliEpisodes = localLibraryItem.media.episodes || []
var localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
const lliEpisodes = localLibraryItem.media.episodes || []
const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
if (localEpisode) {
// Add new local media progress
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",
"version": "0.9.60-beta",
"version": "0.9.61-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
@@ -12,21 +12,21 @@
"ionic:serve": "npm run start"
},
"dependencies": {
"@capacitor/android": "^3.4.3",
"@capacitor/app": "^1.1.1",
"@capacitor/cli": "^3.4.3",
"@capacitor/core": "^3.4.3",
"@capacitor/dialog": "^1.0.7",
"@capacitor/haptics": "^1.1.4",
"@capacitor/ios": "^3.2.2",
"@capacitor/network": "^1.0.7",
"@capacitor/status-bar": "^1.0.8",
"@capacitor/storage": "^1.2.5",
"@capacitor/android": "^4.0.0",
"@capacitor/app": "^4.0.0",
"@capacitor/core": "^4.0.0",
"@capacitor/dialog": "^4.0.0",
"@capacitor/haptics": "^4.0.0",
"@capacitor/ios": "^4.0.0",
"@capacitor/network": "^4.0.0",
"@capacitor/preferences": "^4.0.2",
"@capacitor/status-bar": "^4.0.0",
"@nuxtjs/axios": "^5.13.6",
"cordova-plugin-screen-orientation": "^3.0.2",
"core-js": "^3.15.1",
"date-fns": "^2.25.0",
"epubjs": "^0.3.88",
"fast-average-color": "^9.3.0",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.7",
"socket.io-client": "^4.1.3",
@@ -37,6 +37,7 @@
"devDependencies": {
"@babel/core": "7.13.15",
"@babel/preset-env": "7.13.15",
"@capacitor/cli": "^4.0.0",
"@nuxtjs/tailwindcss": "^4.2.0",
"postcss": "^8.3.5"
}
+1 -1
View File
@@ -53,7 +53,7 @@ export default {
},
methods: {
async logout() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.user) {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
+128 -22
View File
@@ -38,14 +38,36 @@ export default {
return {
shelves: [],
loading: false,
isFirstNetworkConnection: true,
lastServerFetch: 0,
lastServerFetchLibraryId: null,
lastLocalFetch: 0,
localLibraryItems: []
}
},
watch: {
networkConnected(newVal) {
// Update shelves when network connect status changes
console.log(`Network changed to ${newVal} - fetch categories`)
this.fetchCategories()
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()
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: {
@@ -64,6 +86,9 @@ export default {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
currentLibraryIsPodcast() {
return this.currentLibraryMediaType === 'podcast'
},
altViewEnabled() {
return this.$store.getters['getAltViewEnabled']
},
@@ -73,22 +98,66 @@ export default {
},
methods: {
async getLocalMediaItemCategories() {
var localMedia = await this.$db.getLocalLibraryItems()
console.log('Got local library items', localMedia ? localMedia.length : 'N/A')
const localMedia = await this.$db.getLocalLibraryItems()
if (!localMedia || !localMedia.length) return []
var categories = []
var books = []
var podcasts = []
const categories = []
const books = []
const podcasts = []
const booksContinueListening = []
const podcastEpisodesContinueListening = []
localMedia.forEach((item) => {
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)
} 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)
}
})
// 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) {
categories.push({
id: 'local-books',
@@ -97,6 +166,9 @@ export default {
entities: books.sort((a, b) => {
if (a.progress && a.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
})
})
@@ -113,23 +185,53 @@ export default {
return categories
},
async fetchCategories() {
if (this.loading) {
console.log('Already loading categories')
return
console.log(`[categories] fetchCategories networkConnected=${this.networkConnected}, lastServerFetch=${this.lastServerFetch}, lastLocalFetch=${this.lastLocalFetch}`)
// 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
} 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.shelves = []
this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories()
if (this.user && this.currentLibraryId) {
if (this.user && this.currentLibraryId && this.networkConnected) {
this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories()
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 []
})
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) => {
console.log('[breadcrumb] Personalized category from server', cat.type)
if (cat.type == 'book' || cat.type == 'podcast' || cat.type == 'episode') {
// Map localLibraryItem to entities
cat.entities = cat.entities.map((entity) => {
@@ -146,29 +248,32 @@ export default {
})
// 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)
console.log('[categories] Server shelves set', this.shelves.length, this.lastServerFetch)
} else {
// Offline only local
this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories()
this.shelves = localCategories
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
}
this.loading = false
},
async libraryChanged() {
libraryChanged() {
if (this.currentLibraryId) {
await this.fetchCategories()
console.log(`[categories] libraryChanged so fetching categories`)
this.fetchCategories()
}
},
audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
// TODO: Check if audiobook would be on this shelf
if (!this.search) {
this.fetchCategories()
}
},
audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
this.shelves.forEach((shelf) => {
if (shelf.type === 'books') {
shelf.entities = shelf.entities.map((ent) => {
@@ -211,6 +316,7 @@ export default {
},
mounted() {
this.initListeners()
console.log(`[categories] mounted so fetching categories`)
this.fetchCategories()
},
beforeDestroy() {
+1 -1
View File
@@ -3,7 +3,7 @@
<h1 class="text-xl mb-2 font-semibold">Latest Episodes</h1>
<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>
</div>
</template>
+16 -3
View File
@@ -4,7 +4,6 @@
<div class="w-full mx-auto py-5 px-2">
<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-btn type="submit" :disabled="processing" small>Submit</ui-btn> -->
</form>
</div>
@@ -132,14 +131,28 @@ export default {
this.selectedPodcast = podcast
this.showSelectedFeed = true
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>
<style scoped>
.search-results-container {
max-height: calc(100vh - 180px);
max-height: calc(100vh - 182px);
}
.playerOpen .search-results-container {
max-height: calc(100vh - 282px);
}
</style>
+2 -87
View File
@@ -5,94 +5,9 @@
<script>
export default {
data() {
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
}
}
return {}
},
computed: {},
methods: {},
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>
+207 -141
View File
@@ -1,147 +1,143 @@
<template>
<div class="w-full h-full px-3 py-4 overflow-y-auto">
<div class="w-full flex justify-center relative mb-2">
<div class="relative" @click="showFullscreenCover = true">
<covers-book-cover :library-item="libraryItem" :width="175" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<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>
<button class="absolute top-0 right-0 px-1 outline-none" @click="moreButtonPress">
<span class="material-icons text-xl">more_vert</span>
</button>
<div class="w-full h-full px-3 py-4 overflow-y-auto relative bg-bg">
<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>
<h1 class="text-lg font-semibold">{{ title }}</h1>
<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">
<template v-for="(series, index) in seriesList"
><nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`">{{ series.text }}</nuxt-link
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span></template
>
</p>
<p 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">
by
<template v-for="(author, index) in bookAuthors"
><nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`">{{ author.name }}</nuxt-link
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,&nbsp;</span></template
>
</p>
<!-- 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-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">
<div class="w-24">
<span class="text-white text-opacity-60 uppercase text-xs">Narrators</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis text-sm">
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="publishedYear" class="flex py-0.5">
<div class="w-24">
<span class="text-white text-opacity-60 uppercase text-xs">Publish Year</span>
</div>
<div class="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">
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="numTracks" class="flex text-gray-100 text-xs my-2 -mx-0.5">
<div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ $elapsedPretty(duration) }}</p>
</div>
<!-- TODO: Local books dont save the size -->
<div v-if="size" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ $bytesPretty(size) }}</p>
</div>
<div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ numTracks }} Track{{ numTracks > 1 ? 's' : '' }}</p>
</div>
<div v-if="numChapters" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ numChapters }} Chapter{{ numChapters > 1 ? 's' : '' }}</p>
</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 class="z-10 relative">
<div class="w-full flex justify-center relative mb-4">
<div class="relative" @click="showFullscreenCover = true">
<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: coverWidth * progressPercent + 'px' }"></div>
</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" />
<h1 class="text-xl 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="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
<template v-for="(series, index) in seriesList">
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span>
</template>
</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">
by
<template v-for="(author, index) in bookAuthors">
<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>
</template>
</p>
<!-- 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-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
<!-- action buttons -->
<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 -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-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 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">
<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>
</template>
</div>
<div v-if="publishedYear" class="text-white text-opacity-60 uppercase text-sm">Published</div>
<div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
<div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">Genres</div>
<div v-if="genres.length" class="truncate text-sm">
<template v-for="(genre, index) in genres">
<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>
</template>
</div>
</div>
<div v-if="numTracks" class="flex text-gray-100 text-xs my-2 -mx-0.5">
<div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ $elapsedPretty(duration) }}</p>
</div>
<!-- TODO: Local books dont save the size -->
<div v-if="size" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ $bytesPretty(size) }}</p>
</div>
<div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ numTracks }} Track{{ numTracks > 1 ? 's' : '' }}</p>
</div>
<div v-if="numChapters" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ numChapters }} Chapter{{ numChapters > 1 ? 's' : '' }}</p>
</div>
</div>
<div class="w-full py-4">
<p class="text-sm">{{ description }}</p>
</div>
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
<modals-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
</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">
<p class="text-sm">{{ description }}</p>
</div>
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
<modals-dialog v-model="showMoreMenu" title="" :items="moreMenuItems" @action="moreMenuAction" />
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
import { FastAverageColor } from 'fast-average-color'
export default {
async asyncData({ store, params, redirect, app }) {
@@ -181,7 +177,10 @@ export default {
showSelectLocalFolder: false,
showMoreMenu: false,
showDetailsModal: false,
showFullscreenCover: false
showFullscreenCover: false,
coverRgb: 'rgb(55, 56, 56)',
coverBgIsLight: false,
windowWidth: 0
}
},
computed: {
@@ -299,7 +298,7 @@ export default {
},
userTimeRemaining() {
if (!this.userItemProgress) return 0
var duration = this.userItemProgress.duration || this.duration
const duration = this.userItemProgress.duration || this.duration
return duration - this.userItemProgress.currentTime
},
progressPercent() {
@@ -315,6 +314,7 @@ export default {
return this.isPlaying && !this.$store.state.playerIsLocal
},
isPlaying() {
if (this.localLibraryItemId && this.$store.getters['getIsItemStreaming'](this.localLibraryItemId)) return true
return this.$store.getters['getIsItemStreaming'](this.libraryItemId)
},
numTracks() {
@@ -359,6 +359,22 @@ export default {
},
moreMenuItems() {
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) {
items.push({
text: 'Manage Local Files',
@@ -366,11 +382,6 @@ export default {
})
}
items.push({
text: 'View Details',
value: 'details'
})
if (!this.isPodcast && this.serverLibraryItemId) {
items.push({
text: 'Add to Playlist',
@@ -378,19 +389,57 @@ export default {
})
}
items.push({
text: 'More Info',
value: 'details'
})
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: {
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) {
this.showMoreMenu = false
if (action === 'manageLocal') {
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
this.$nextTick(() => {
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
})
} else if (action === 'details') {
this.showDetailsModal = true
} else if (action === 'playlist') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: null }])
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() {
@@ -401,7 +450,7 @@ export default {
},
async playClick() {
let episodeId = null
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isPodcast) {
this.episodes.sort((a, b) => {
@@ -455,7 +504,8 @@ export default {
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId })
},
async clearProgressClick() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to reset your progress?'
@@ -508,7 +558,7 @@ export default {
if (!this.numTracks) {
return
}
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (this.isIos) {
// no local folders on iOS
this.startDownload()
@@ -576,7 +626,17 @@ export default {
}
},
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
if (this.isLocal) {
var isFinished = !this.userIsFinished
@@ -624,14 +684,20 @@ export default {
if (this.libraryItem.libraryId !== libraryId) {
this.$router.replace('/bookshelf')
}
},
windowResized() {
this.windowWidth = window.innerWidth
}
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.windowResized)
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
this.$socket.$on('item_updated', this.itemUpdated)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResized)
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
this.$socket.$off('item_updated', this.itemUpdated)
+2 -2
View File
@@ -256,7 +256,7 @@ export default {
this.showDialog = true
},
async play() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItemId })
},
getCapImageSrc(contentUrl) {
@@ -264,7 +264,7 @@ export default {
},
async dialogAction(action) {
console.log('Dialog action', action)
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
if (action == 'scan') {
this.scanItem()
} 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>
<div class="w-full h-full">
<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 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">
+184 -25
View File
@@ -1,19 +1,27 @@
<template>
<div class="w-full h-full p-8">
<p class="uppercase text-xs font-semibold text-gray-300 mb-2">Display Settings</p>
<div class="w-full h-full px-8 py-8 overflow-y-auto">
<!-- 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="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.enableAltView" @input="saveSettings" />
<ui-toggle-switch v-model="enableBookshelfView" @input="saveSettings" />
</div>
<p class="pl-4">Alternative bookshelf view</p>
<p class="pl-4">Use bookshelf view</p>
</div>
<div class="flex items-center py-3" @click.stop="toggleLockOrientation">
<div class="w-10 flex justify-center">
<ui-toggle-switch v-model="lockCurrentOrientation" @input="saveSettings" />
<div class="w-10 flex justify-center pointer-events-none">
<ui-toggle-switch v-model="lockCurrentOrientation" />
</div>
<p class="pl-4">Lock orientation</p>
</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>
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind">
<div class="w-10 flex justify-center">
@@ -34,14 +42,48 @@
<p class="pl-4">Jump forwards time</p>
</div>
<p v-if="!isiOS" class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Sleep Timer Settings</p>
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableShakeToResetSleepTimer">
<div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" />
<!-- Sleep timer settings -->
<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">
<ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" />
</div>
<p class="pl-4">Disable shake to reset</p>
<span class="material-icons-outlined ml-2" @click.stop="showInfo('disableShakeToResetSleepTimer')">info</span>
</div>
<p class="pl-4">Disable shake to reset</p>
<span class="material-icons-outlined ml-2" @click.stop="showInfo('disableShakeToResetSleepTimer')">info</span>
<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>
</template>
@@ -52,24 +94,86 @@ export default {
data() {
return {
deviceData: null,
showMoreMenuDialog: false,
showSleepTimerLengthModal: false,
moreMenuSetting: '',
settings: {
disableAutoRewind: false,
enableAltView: false,
jumpForwardTime: 10,
jumpBackwardsTime: 10,
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: {
disableShakeToResetSleepTimer: {
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: {
// 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() {
return this.$platform === 'ios'
},
@@ -92,9 +196,61 @@ export default {
currentJumpBackwardsTimeIndex() {
var index = this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
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: {
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) {
if (this.settingInfo[setting]) {
Dialog.alert({
@@ -103,6 +259,10 @@ export default {
})
}
},
toggleAutoSleepTimer() {
this.settings.autoSleepTimer = !this.settings.autoSleepTimer
this.saveSettings()
},
toggleDisableShakeToResetSleepTimer() {
this.settings.disableShakeToResetSleepTimer = !this.settings.disableShakeToResetSleepTimer
this.saveSettings()
@@ -118,23 +278,18 @@ export default {
getCurrentOrientation() {
const orientation = window.screen ? window.screen.orientation || {} : {}
const type = orientation.type || ''
console.log('getCurrentOrientation=' + type)
if (type.includes('landscape')) return 'LANDSCAPE'
return 'PORTRAIT' // default
},
toggleLockOrientation() {
console.log('TOGGLE LOCK ORIENTATION', this.lockCurrentOrientation)
this.lockCurrentOrientation = !this.lockCurrentOrientation
if (this.lockCurrentOrientation) {
console.log('CURRENT ORIENTATION=', this.getCurrentOrientation())
this.settings.lockOrientation = this.getCurrentOrientation()
} else {
console.log('SETTING CURRENT ORIENTATION TO NONE')
this.settings.lockOrientation = 'NONE'
}
this.$setOrientationLock(this.settings.lockOrientation)
console.log('NOW SAVING SETTINGS', this.settings.lockOrientation)
this.saveSettings()
},
toggleJumpForward() {
@@ -149,9 +304,8 @@ export default {
this.saveSettings()
},
async saveSettings() {
await this.$hapticsImpactMedium()
await this.$hapticsImpact()
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
console.log('Saved device data', updatedDeviceData)
if (updatedDeviceData) {
this.$store.commit('setDeviceData', updatedDeviceData)
this.init()
@@ -166,11 +320,16 @@ export default {
this.settings.enableAltView = !!deviceSettings.enableAltView
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
this.settings.disableShakeToResetSleepTimer = !!deviceSettings.disableShakeToResetSleepTimer
this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE'
console.log('INIT SETTINGS LOCK ORIENTATION=', this.settings.lockOrientation)
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() {
+1 -2
View File
@@ -27,7 +27,6 @@ export default function ({ $axios, store }) {
})
$axios.onError(error => {
const code = parseInt(error.response && error.response.status)
console.error('Axios error code', code)
console.error('Axios error code', error)
})
}
+2 -1
View File
@@ -43,7 +43,7 @@ class AbsAudioPlayerWeb extends WebPlugin {
}
// PluginMethod
async prepareLibraryItem({ libraryItemId, episodeId, playWhenReady }) {
async prepareLibraryItem({ libraryItemId, episodeId, playWhenReady, startTime }) {
console.log('[AbsAudioPlayer] Prepare library item', libraryItemId)
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 playbackSession = await $axios.$post(route, { mediaPlayer: 'html5-mobile', forceDirectPlay: true })
if (playbackSession) {
if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime
this.setAudioPlayer(playbackSession, true)
}
}
+127 -1
View File
@@ -212,11 +212,137 @@ class AbsDatabaseWeb extends WebPlugin {
}
async updateDeviceSettings(payload) {
var deviceData = await this.getDeviceData()
const deviceData = await this.getDeviceData()
deviceData.deviceSettings = payload
localStorage.setItem('device', JSON.stringify(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', {
+8
View File
@@ -62,6 +62,10 @@ class DbService {
return AbsDatabase.getAllLocalMediaProgress().then((data) => data.value)
}
getLocalMediaProgressForServerItem(payload) {
return AbsDatabase.getLocalMediaProgressForServerItem(payload)
}
removeLocalMediaProgress(localMediaProgressId) {
return AbsDatabase.removeLocalMediaProgress({ localMediaProgressId })
}
@@ -86,6 +90,10 @@ class DbService {
updateDeviceSettings(payload) {
return AbsDatabase.updateDeviceSettings(payload)
}
getMediaItemHistory(mediaId) {
return AbsDatabase.getMediaItemHistory({ mediaId })
}
}
export default ({ app, store }, inject) => {
+12 -2
View File
@@ -4,7 +4,7 @@ import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics"
const hapticsImpactHeavy = async () => {
await Haptics.impact({ style: ImpactStyle.Heavy })
}
Vue.prototype.$hapticsImpactHeavy = hapticsImpactHeavy;
Vue.prototype.$hapticsImpactHeavy = hapticsImpactHeavy
const hapticsImpactMedium = async () => {
await Haptics.impact({ style: ImpactStyle.Medium })
@@ -19,7 +19,7 @@ Vue.prototype.$hapticsImpactLight = hapticsImpactLight
const hapticsVibrate = async () => {
await Haptics.vibrate()
}
Vue.prototype.$hapticsVibrate = hapticsVibrate;
Vue.prototype.$hapticsVibrate = hapticsVibrate
const hapticsNotificationSuccess = async () => {
await Haptics.notification({ type: NotificationType.Success })
@@ -50,3 +50,13 @@ const hapticsSelectionEnd = async () => {
await Haptics.selectionEnd()
}
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 { Capacitor } from '@capacitor/core';
Vue.prototype.$eventBus = new Vue()
if (Capacitor.getPlatform() != 'web') {
const setStatusBarStyleDark = async () => {
await StatusBar.setStyle({ style: Style.Dark })
@@ -93,10 +91,10 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
let _seconds = seconds
let _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60)
let _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.floor(_seconds)
if (!_hours) {
@@ -105,6 +103,16 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
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 = ' - ') => {
if (typeof input !== 'string') {
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
// backButton event does not work with iOS swipe navigation so use this workaround
if (app.router && Capacitor.getPlatform() === 'ios') {

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