diff --git a/android/app/build.gradle b/android/app/build.gradle index 163290a6..7f5e8ab6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -21,7 +21,11 @@ kotlin { } android { - kotlinOptions { + + buildFeatures { + viewBinding true + } + kotlinOptions { freeCompilerArgs = ['-Xjvm-default=all'] } compileSdkVersion rootProject.ext.compileSdkVersion @@ -29,8 +33,8 @@ android { applicationId "com.audiobookshelf.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 82 - versionName "0.9.51-beta" + versionCode 86 + versionName "0.9.55-beta" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -111,7 +115,7 @@ dependencies { implementation 'io.github.pilgr:paperdb:2.7.2' // Simple Storage - implementation "com.anggrayudi:storage:0.13.0" + implementation "com.anggrayudi:storage:0.14.0" // OK HTTP implementation 'com.squareup.okhttp3:okhttp:4.9.2' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e737fd2b..85781ea0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,83 +1,94 @@ + xmlns:dist="http://schemas.android.com/apk/distribution" + xmlns:tools="http://schemas.android.com/tools" + package="com.audiobookshelf.app" + android:installLocation="preferExternal" > - - + + android:usesCleartextTraffic="true" > + + + + + + + + + + - - + - - - + android:name="com.google.android.gms.car.notification.SmallIcon" + android:resource="@drawable/icon" /> + + + android:label="@string/title_activity_main" + android:launchMode="singleTask" + android:theme="@style/AppTheme.NoActionBarLaunch" > + + - - - - + + + + - - - - + + - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + diff --git a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt index c1f60c88..8e2e0bae 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt @@ -19,7 +19,6 @@ import com.audiobookshelf.app.plugins.AbsAudioPlayer import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsFileSystem import com.getcapacitor.BridgeActivity -import io.paperdb.Paper class MainActivity : BridgeActivity() { @@ -58,10 +57,6 @@ class MainActivity : BridgeActivity() { DbManager.initialize(applicationContext) - // Grant full storage access for testing - // var ss = SimpleStorage(this) - // ss.requestFullStorageAccess() - val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) if (permission != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, @@ -97,9 +92,7 @@ class MainActivity : BridgeActivity() { foregroundService = mLocalBinder.getService() // Let NativeAudio know foreground service is ready and setup event listener - if (pluginCallback != null) { - pluginCallback() - } + pluginCallback() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/NewAppWidget.kt b/android/app/src/main/java/com/audiobookshelf/app/NewAppWidget.kt new file mode 100644 index 00000000..c160ca06 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/NewAppWidget.kt @@ -0,0 +1,96 @@ +package com.audiobookshelf.app + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE +import android.util.Log +import android.widget.RemoteViews +import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.device.WidgetEventEmitter +import com.audiobookshelf.app.player.PlayerNotificationService +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.AppWidgetTarget +import com.bumptech.glide.request.transition.Transition + +/** + * Implementation of App Widget functionality. + */ +class NewAppWidget : AppWidgetProvider() { + val tag = "NewAppWidget" + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + // There may be multiple widgets active, so update all of them + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId, null,false) + } + } + + override fun onEnabled(context: Context) { + Log.w(tag, "onEnabled check context ${context.packageName}") + + // Enter relevant functionality for when the first widget is created + DeviceManager.widgetUpdater = (object : WidgetEventEmitter { + override fun onPlayerChanged(pns:PlayerNotificationService) { + val isPlaying = pns.currentPlayer.isPlaying + Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying") + + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, NewAppWidget::class.java) + val ids = appWidgetManager.getAppWidgetIds(componentName) + + val playbackSession = pns.getCurrentPlaybackSessionCopy() + val cover = playbackSession?.getCoverUri() + + for (widgetId in ids) { + updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying) + } + } + }) + } + + override fun onDisabled(context: Context) { + // Enter relevant functionality for when the last widget is disabled + } +} + +internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) { + + val views = RemoteViews(context.packageName, R.layout.new_app_widget) + + val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE) + views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI) + + val wholeWidgetClickI = Intent(context, MainActivity::class.java) + wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + val wholeWidgetClickPI = PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + wholeWidgetClickI, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI) + + val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + super.onResourceReady(resource, transition) + } + } + + val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon) + Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt) + + val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark + views.setImageViewResource(R.id.playPauseIcon, playPauseResource) + + // Instruct the widget manager to update the widget + appWidgetManager.updateAppWidget(appWidgetId, views) +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt index 79676738..f7da6233 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt @@ -45,7 +45,7 @@ class DbManager { } } - fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? { + fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? { return getLocalLibraryItems().find { it.libraryItemId == libraryItemId } } @@ -213,7 +213,11 @@ class DbManager { val localLibraryItems = getLocalLibraryItems() localMediaProgress.forEach { val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId } - if (matchingLLI == null) { + if (!it.id.startsWith("local")) { + // A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them. + Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)") + Paper.book("localMediaProgress").delete(it.id) + } else if (matchingLLI == null) { Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing") Paper.book("localMediaProgress").delete(it.id) } else if (matchingLLI.isPodcast) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 9007f748..6b658011 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -18,13 +18,19 @@ data class ServerConnectionConfig( data class DeviceSettings( var disableAutoRewind:Boolean, + var enableAltView:Boolean, var jumpBackwardsTime:Int, var jumpForwardTime:Int ) { companion object { // Static method to get default device settings fun default():DeviceSettings { - return DeviceSettings(false, 10, 10) + return DeviceSettings( + disableAutoRewind = false, + enableAltView = false, + jumpBackwardsTime = 10, + jumpForwardTime = 10 + ) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt index 75ac9a15..3a5d7462 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt @@ -2,6 +2,7 @@ package com.audiobookshelf.app.data import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import kotlin.math.roundToInt @JsonIgnoreProperties(ignoreUnknown = true) data class LocalMediaProgress( @@ -22,6 +23,9 @@ data class LocalMediaProgress( var libraryItemId:String?, var episodeId:String? ) { + @get:JsonIgnore + val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt() + @JsonIgnore fun updateIsFinished(finished:Boolean) { if (isFinished != finished) { // If finished changed then set progress diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index d233ffc4..ef6b65cd 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -1,9 +1,12 @@ package com.audiobookshelf.app.device import android.util.Log -import com.audiobookshelf.app.data.DbManager -import com.audiobookshelf.app.data.DeviceData -import com.audiobookshelf.app.data.ServerConnectionConfig +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.player.PlayerNotificationService + +interface WidgetEventEmitter { + fun onPlayerChanged(pns:PlayerNotificationService) +} object DeviceManager { const val tag = "DeviceManager" @@ -19,6 +22,8 @@ object DeviceManager { val isConnectedToServer get() = serverConnectionConfig != null val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null + var widgetUpdater:WidgetEventEmitter? = null + init { Log.d(tag, "Device Manager Singleton invoked") } diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index eedbd96c..945ab001 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -1,21 +1,24 @@ package com.audiobookshelf.app.media +import android.app.Activity import android.content.Context import android.support.v4.media.MediaBrowserCompat import android.util.Log import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.server.ApiHandler +import com.getcapacitor.JSObject import java.util.* import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking +import org.json.JSONException import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { val tag = "MediaManager" - var serverLibraryItems = listOf() + var serverLibraryItems = mutableListOf() var selectedLibraryId = "" var selectedLibraryItemWrapper:LibraryItemWrapper? = null @@ -26,10 +29,35 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { var serverLibraries = listOf() var serverConfigIdUsed:String? = null + var userSettingsPlaybackRate:Float? = null + fun getIsLibrary(id:String) : Boolean { return serverLibraries.find { it.id == id } != null } + fun getSavedPlaybackRate():Float { + if (userSettingsPlaybackRate != null) { + return userSettingsPlaybackRate ?: 1f + } + + val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) + if (sharedPrefs != null) { + val userSettingsPref = sharedPrefs.getString("userSettings", null) + if (userSettingsPref != null) { + try { + val userSettings = JSObject(userSettingsPref) + if (userSettings.has("playbackRate")) { + userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat() + return userSettingsPlaybackRate ?: 1f + } + } catch(je:JSONException) { + Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}") + } + } + } + return 1f + } + fun checkResetServerItems() { // When opening android auto need to check if still connected to server // and reset any server data already set @@ -39,7 +67,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { serverPodcastEpisodes = listOf() serverLibraryCategories = listOf() serverLibraries = listOf() - serverLibraryItems = listOf() + serverLibraryItems = mutableListOf() selectedLibraryId = "" } } @@ -63,7 +91,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId - serverLibraryItems = libraryItemsWithAudio + libraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } cb(libraryItemsWithAudio) } } @@ -167,6 +199,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { if (result) { hasValidConn = true DeviceManager.serverConnectionConfig = config + Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}") break } } @@ -204,21 +237,37 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { serverConfigIdUsed = DeviceManager.serverConnectionConfigId loadLibraries { libraries -> - val library = libraries[0] - Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") + if (libraries.isEmpty()) { + Log.w(tag, "No libraries returned from server request") + cb(cats) // Return download category only + } else { + val library = libraries[0] + Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") - loadLibraryCategories(library.id) { libraryCategories -> + loadLibraryCategories(library.id) { libraryCategories -> - // Only using book or podcast library categories for now - libraryCategories.forEach { - // Log.d(tag, "Found library category ${it.label} with type ${it.type}") - if (it.type == library.mediaType) { - // Log.d(tag, "Using library category ${it.id}") - cats.add(it) + // Only using book or podcast library categories for now + libraryCategories.forEach { + + // Add items in continue listening to serverLibraryItems + if (it.id == "continue-listening") { + it.entities.forEach { libraryItemWrapper -> + val libraryItem = libraryItemWrapper as LibraryItem + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + } + + // Log.d(tag, "Found library category ${it.label} with type ${it.type}") + if (it.type == library.mediaType) { + // Log.d(tag, "Using library category ${it.id}") + cats.add(it) + } } - } - cb(cats) + cb(cats) + } } } } else { // Not connected/no internet sent downloaded cats only @@ -265,19 +314,18 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { } fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) { - if (libraryItemWrapper is LocalLibraryItem) { - val localLibraryItem = libraryItemWrapper as LocalLibraryItem - cb(localLibraryItem.getPlaybackSession(episode)) - } else { - val libraryItem = libraryItemWrapper as LibraryItem - apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "",playItemRequestPayload) { - if (it == null) { - cb(null) - } else { - cb(it) - } - } - } + if (libraryItemWrapper is LocalLibraryItem) { + cb(libraryItemWrapper.getPlaybackSession(episode)) + } else { + val libraryItem = libraryItemWrapper as LibraryItem + apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) { + if (it == null) { + cb(null) + } else { + cb(it) + } + } + } } private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index 316abc51..115f6b3d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -4,7 +4,6 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import android.support.v4.media.MediaMetadataCompat -import android.util.Log import androidx.annotation.AnyRes import com.audiobookshelf.app.R import com.audiobookshelf.app.data.Library diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt index 4aa370f3..c8803565 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt @@ -10,7 +10,6 @@ import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.server.ApiHandler import java.util.* import kotlin.concurrent.schedule -import kotlin.math.roundToInt data class MediaProgressSyncData( var timeListened:Long, // seconds @@ -20,6 +19,7 @@ data class MediaProgressSyncData( class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) { private val tag = "MediaProgressSync" + private val METERED_CONNECTION_SYNC_INTERVAL = 60000 private var listeningTimerTask: TimerTask? = null var listeningTimerRunning:Boolean = false @@ -43,88 +43,163 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic currentLocalMediaProgress = null listeningTimerTask?.cancel() lastSyncTime = 0L + Log.d(tag, "start: Set last sync time 0 $lastSyncTime") failedSyncs = 0 } else { return } + } else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) { + currentLocalMediaProgress = null } + listeningTimerRunning = true lastSyncTime = System.currentTimeMillis() + Log.d(tag, "start: init last sync time $lastSyncTime") currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy() listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { 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 + val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL + val currentTime = playerNotificationService.getCurrentTimeSeconds() - sync(currentTime) + if (currentTime > 0) { + sync(shouldSyncServer, currentTime) { + Log.d(tag, "Sync complete") + } + } } } } } - fun stop() { + fun stop(cb: () -> Unit) { if (!listeningTimerRunning) return + listeningTimerTask?.cancel() + listeningTimerTask = null + listeningTimerRunning = false Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") val currentTime = playerNotificationService.getCurrentTimeSeconds() - sync(currentTime) - reset() + if (currentTime > 0) { // Current time should always be > 0 on stop + sync(true, currentTime) { + reset() + cb() + } + } else { + reset() + cb() + } + } + + fun pause(cb: () -> Unit) { + if (!listeningTimerRunning) return + listeningTimerTask?.cancel() + listeningTimerTask = null + listeningTimerRunning = false + Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle") + Log.d(tag, "pause: Last sync time $lastSyncTime") + + val currentTime = playerNotificationService.getCurrentTimeSeconds() + if (currentTime > 0) { // Current time should always be > 0 on pause + sync(true, currentTime) { + lastSyncTime = 0L + Log.d(tag, "pause: Set last sync time 0 $lastSyncTime") + failedSyncs = 0 + cb() + } + } else { + lastSyncTime = 0L + Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)") + failedSyncs = 0 + cb() + } + } fun syncFromServerProgress(mediaProgress: MediaProgress) { currentPlaybackSession?.let { it.updatedAt = mediaProgress.lastUpdate it.currentTime = mediaProgress.currentTime - DeviceManager.dbManager.saveLocalPlaybackSession(it) saveLocalProgress(it) } } - fun sync(currentTime:Double) { - val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime - if (diffSinceLastSync < 1000L) { + fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) { + if (lastSyncTime <= 0) { + Log.e(tag, "Last sync time is not set $lastSyncTime") return } + + val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime + if (diffSinceLastSync < 1000L) { + return cb() + } val listeningTimeToAdd = diffSinceLastSync / 1000L - lastSyncTime = System.currentTimeMillis() val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime) currentPlaybackSession?.syncData(syncData) + + if (currentPlaybackSession?.progress?.isNaN() == true) { + Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}") + return cb() + } + if (currentIsLocal) { // Save local progress sync currentPlaybackSession?.let { DeviceManager.dbManager.saveLocalPlaybackSession(it) saveLocalProgress(it) + lastSyncTime = System.currentTimeMillis() // Local library item is linked to a server library item - if (!it.libraryItemId.isNullOrEmpty()) { - // Send sync to server also if connected to this server and local item belongs to this server - if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { - apiHandler.sendLocalProgressSync(it) { - Log.d( - tag, - "Local progress sync data sent to server $currentDisplayTitle for time $currentTime" - ) + // 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 -> + Log.d( + tag, + "Local progress sync data sent to server $currentDisplayTitle for time $currentTime" + ) + if (syncSuccess) { + failedSyncs = 0 + playerNotificationService.alertSyncSuccess() + } else { + failedSyncs++ + if (failedSyncs == 2) { + playerNotificationService.alertSyncFailing() // Show alert in client + failedSyncs = 0 + } + Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime") } + + cb() } + } else { + cb() } } - } else { + } else if (shouldSyncServer) { apiHandler.sendProgressSync(currentSessionId, syncData) { if (it) { Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime") failedSyncs = 0 + playerNotificationService.alertSyncSuccess() + lastSyncTime = System.currentTimeMillis() } else { failedSyncs++ if (failedSyncs == 2) { playerNotificationService.alertSyncFailing() // Show alert in client failedSyncs = 0 } - Log.d(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime") + Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime") } + cb() } + } else { + cb() } } @@ -140,19 +215,22 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession) } currentLocalMediaProgress?.let { - 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.progress * 100).roundToInt()}%") + 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}%") + } } } fun reset() { - listeningTimerTask?.cancel() - listeningTimerTask = null - listeningTimerRunning = false currentPlaybackSession = null currentLocalMediaProgress = null lastSyncTime = 0L + Log.d(tag, "reset: Set last sync time 0 $lastSyncTime") failedSyncs = 0 } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt index 7593e0f9..3809286d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -7,8 +7,10 @@ import android.os.Handler import android.os.Looper import android.os.Message import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import android.view.KeyEvent +import com.audiobookshelf.app.R import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.PodcastEpisode import java.util.* @@ -28,8 +30,9 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi if (it == null) { Log.e(tag, "Failed to play library item") } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() Handler(Looper.getMainLooper()).post() { - playerNotificationService.preparePlayer(it,true,null) + playerNotificationService.preparePlayer(it,true, playbackRate) } } } @@ -53,8 +56,9 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi if (it == null) { Log.e(tag, "Failed to play library item") } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() Handler(Looper.getMainLooper()).post() { - playerNotificationService.preparePlayer(it, true, null) + playerNotificationService.preparePlayer(it, true, playbackRate) } } } @@ -114,8 +118,9 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi if (it == null) { Log.e(tag, "Failed to play library item") } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() Handler(Looper.getMainLooper()).post() { - playerNotificationService.preparePlayer(it, true, null) + playerNotificationService.preparePlayer(it, true, playbackRate) } } } @@ -127,8 +132,25 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi } fun handleCallMediaButton(intent: Intent): Boolean { + Log.w(tag, "handleCallMediaButton $intent | ${intent.action}") + if(Intent.ACTION_MEDIA_BUTTON == intent.action) { val keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}") + + if (keyEvent?.action == KeyEvent.ACTION_DOWN) { + Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}") + when (keyEvent.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + Log.d(tag, "handleCallMediaButton: Media Play/Pause") + if (playerNotificationService.mPlayer.isPlaying) { + playerNotificationService.pause() + } else { + playerNotificationService.play() + } + } + } + } if (keyEvent?.action == KeyEvent.ACTION_UP) { Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}") @@ -214,4 +236,12 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi } } + // Example Using a custom action in android auto +// override fun onCustomAction(action: String?, extras: Bundle?) { +// super.onCustomAction(action, extras) +// +// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) { +// +// } +// } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt index 33381e07..536f600a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt @@ -34,8 +34,9 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat if (it == null) { Log.e(tag, "Failed to play library item") } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() Handler(Looper.getMainLooper()).post() { - playerNotificationService.preparePlayer(it, playWhenReady, null) + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) } } } @@ -61,8 +62,9 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat if (it == null) { Log.e(tag, "Failed to play library item") } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() Handler(Looper.getMainLooper()).post() { - playerNotificationService.preparePlayer(it, playWhenReady, null) + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) } } } @@ -76,8 +78,9 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat if (it == null) { Log.e(tag, "Failed to play library item") } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() Handler(Looper.getMainLooper()).post() { - playerNotificationService.preparePlayer(it, playWhenReady, null) + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt index cc9f1e3c..239ebb97 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -71,32 +71,36 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : 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") - var backTime = calcPauseSeekBackTime() - if (backTime > 0) { + 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 - backTime + val newTime = currentTime - seekBackTime if (newTime < minSeekBackTime) { - backTime = currentTime - minSeekBackTime + seekBackTime = currentTime - minSeekBackTime } - Log.d(tag, "SeekBackTime $backTime") + Log.d(tag, "SeekBackTime $seekBackTime") onSeekBack = true - playerNotificationService.seekBackward(backTime) } } // 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() + 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") @@ -104,15 +108,18 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : } // Start/stop progress sync interval - Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}") if (player.isPlaying) { player.volume = 1F // Volume on sleep timer might have decreased this playerNotificationService.mediaProgressSyncer.start() } else { - playerNotificationService.mediaProgressSyncer.stop() + playerNotificationService.mediaProgressSyncer.pause { + Log.d(tag, "Media Progress Syncer paused and synced") + } } playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying) + + DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 407c64c0..86822961 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -6,6 +6,10 @@ import android.content.Intent import android.graphics.Color import android.hardware.Sensor import android.hardware.SensorManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.os.* import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat @@ -15,9 +19,12 @@ import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.R import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.DeviceInfo import com.audiobookshelf.app.device.DeviceManager @@ -42,6 +49,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { companion object { var isStarted = false var isClosed = false + var isUnmeteredNetwork = false } interface ClientEventEmitter { @@ -55,6 +63,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun onPlaybackFailed(errorMessage:String) fun onMediaPlayerChanged(mediaPlayer:String) fun onProgressSyncFailing() + fun onProgressSyncSuccess() + fun onNetworkMeteredChanged(isUnmetered:Boolean) } private val tag = "PlayerService" @@ -65,7 +75,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private lateinit var ctx:Context private lateinit var mediaSessionConnector: MediaSessionConnector private lateinit var playerNotificationManager: PlayerNotificationManager - private lateinit var mediaSession: MediaSessionCompat + lateinit var mediaSession: MediaSessionCompat private lateinit var transportControls:MediaControllerCompat.TransportControls lateinit var mediaManager: MediaManager @@ -160,6 +170,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { super.onCreate() ctx = this + // To listen for network change from metered to unmetered + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .build() + val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + DbManager.initialize(ctx) // Initialize API @@ -191,6 +210,7 @@ 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) @@ -261,6 +281,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) + // Example adding custom action with icon in android auto +// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider { +// override fun onCustomAction(player: Player, action: String, extras: Bundle?) { +// } +// override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { +// var icon = R.drawable.exo_icon_rewind +// return PlaybackStateCompat.CustomAction.Builder( +// "com.audiobookshelf.app.PLAYBACK_RATE", "Playback Rate", icon) +// .build() +// } +// }) + mediaSession.setCallback(MediaSessionCallback(this)) initializeMPlayer() @@ -300,6 +332,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { User callable methods */ fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) { + if (!isStarted) { + Log.i(tag, "preparePlayer: foreground service not started - Starting service --") + Intent(ctx, PlayerNotificationService::class.java).also { intent -> + ContextCompat.startForegroundService(ctx, intent) + } + } + isClosed = false val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f initialPlaybackRate = playbackRate @@ -527,10 +566,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // Called from PlayerListener play event // check with server if progress has updated since last play and sync progress update - fun checkCurrentSessionProgress():Boolean { + fun checkCurrentSessionProgress(seekBackTime:Long):Boolean { if (currentPlaybackSession == null) return true - currentPlaybackSession?.let { playbackSession -> + mediaProgressSyncer.currentPlaybackSession?.let { playbackSession -> if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) { return true // carry on } @@ -552,16 +591,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}") mediaProgressSyncer.syncFromServerProgress(mediaProgress) + // Update current playback session stored in PNS since MediaProgressSyncer version is a copy + mediaProgressSyncer.currentPlaybackSession?.let { updatedPlaybackSession -> + currentPlaybackSession = updatedPlaybackSession + } + Handler(Looper.getMainLooper()).post { seekPlayer(playbackSession.currentTimeMs) + // Should already be playing + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + mediaProgressSyncer.start() + clientEventEmitter?.onPlayingUpdate(true) + } + } else { + Handler(Looper.getMainLooper()).post { + if (seekBackTime > 0L) { + seekBackward(seekBackTime) + } + // Should already be playing + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + mediaProgressSyncer.start() + clientEventEmitter?.onPlayingUpdate(true) } - } - - Handler(Looper.getMainLooper()).post { - // Should already be playing - currentPlayer.volume = 1F // Volume on sleep timer might have decreased this - mediaProgressSyncer.start() - clientEventEmitter?.onPlayingUpdate(true) } } } else { @@ -584,6 +635,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } else { Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server") Handler(Looper.getMainLooper()).post { + if (seekBackTime > 0L) { + seekBackward(seekBackTime) + } + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this mediaProgressSyncer.start() clientEventEmitter?.onPlayingUpdate(true) @@ -648,6 +703,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun closePlayback() { Log.d(tag, "closePlayback") + if (mediaProgressSyncer.listeningTimerRunning) { + Log.i(tag, "About to close playback so stopping media progress syncer first") + mediaProgressSyncer.stop { + Log.d(tag, "Media Progress syncer stopped and synced") + } + } + try { currentPlayer.stop() currentPlayer.clearMediaItems() @@ -660,6 +722,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { PlayerListener.lastPauseTime = 0 isClosed = true stopForeground(true) + stopSelf() } fun sendClientMetadata(playerState: PlayerState) { @@ -694,6 +757,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { clientEventEmitter?.onProgressSyncFailing() } + fun alertSyncSuccess() { + clientEventEmitter?.onProgressSyncSuccess() + } + // // MEDIA BROWSER STUFF (ANDROID AUTO) // @@ -702,7 +769,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { 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(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) + private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) private val AUTO_MEDIA_ROOT = "/" private val ALL_ROOT = "__ALL__" @@ -863,5 +930,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } } + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + // Network capabilities have changed for the network + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + Log.i(tag, "Network capabilities changed is unmetered = $unmetered") + isUnmeteredNetwork = unmetered + clientEventEmitter?.onNetworkMeteredChanged(unmetered) + } + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index eacf09bf..8d9a62c0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -1,10 +1,8 @@ package com.audiobookshelf.app.plugins -import android.content.Intent import android.os.Handler import android.os.Looper import android.util.Log -import androidx.core.content.ContextCompat import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager @@ -82,6 +80,14 @@ class AbsAudioPlayer : Plugin() { override fun onProgressSyncFailing() { emit("onProgressSyncFailing", "") } + + override fun onProgressSyncSuccess() { + emit("onProgressSyncSuccess", "") + } + + override fun onNetworkMeteredChanged(isUnmetered:Boolean) { + emit("onNetworkMeteredChanged", isUnmetered) + } }) } mainActivity.pluginCallback = foregroundServiceReady @@ -150,14 +156,6 @@ class AbsAudioPlayer : Plugin() { @PluginMethod fun prepareLibraryItem(call: PluginCall) { - // Need to make sure the player service has been started - if (!PlayerNotificationService.isStarted) { - Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --") - Intent(mainActivity, PlayerNotificationService::class.java).also { intent -> - ContextCompat.startForegroundService(mainActivity, intent) - } - } - val libraryItemId = call.getString("libraryItemId", "").toString() val episodeId = call.getString("episodeId", "").toString() val playWhenReady = call.getBoolean("playWhenReady") == true @@ -183,7 +181,21 @@ class AbsAudioPlayer : Plugin() { Handler(Looper.getMainLooper()).post { Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") val playbackSession = it.getPlaybackSession(episode) - playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate) + + 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( + playbackSession, + playWhenReady, + playbackRate + ) + } + } + } else { + playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate) + } } return call.resolve(JSObject()) } @@ -194,9 +206,20 @@ class AbsAudioPlayer : Plugin() { if (it == null) { call.resolve(JSObject("{\"error\":\"Server play request failed\"}")) } else { + Handler(Looper.getMainLooper()).post { - Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}") - playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) + 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) + } + } + } else { + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) + } } call.resolve(JSObject(jacksonMapper.writeValueAsString(it))) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index 3899608a..3076ae6e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -77,10 +77,10 @@ class AbsDatabase : Plugin() { } @PluginMethod - fun getLocalLibraryItemByLLId(call:PluginCall) { + fun getLocalLibraryItemByLId(call:PluginCall) { val libraryItemId = call.getString("libraryItemId", "").toString() GlobalScope.launch(Dispatchers.IO) { - val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId) + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId) if (localLibraryItem == null) { call.resolve() } else { diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt index 4beb4436..3d58cb41 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -1,5 +1,6 @@ package com.audiobookshelf.app.plugins +import android.app.AlertDialog import android.database.Cursor import android.net.Uri import android.os.Build @@ -65,39 +66,59 @@ class AbsFileSystem : Plugin() { @PluginMethod fun selectFolder(call: PluginCall) { - var mediaType = call.data.getString("mediaType", "book").toString() + val mediaType = call.data.getString("mediaType", "book").toString() + val REQUEST_CODE_SELECT_FOLDER = 6 + val REQUEST_CODE_SDCARD_ACCESS = 7 mainActivity.storage.folderPickerCallback = object : FolderPickerCallback { override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") - var absolutePath = folder.getAbsolutePath(activity) - var storageType = folder.getStorageType(activity) - var simplePath = folder.getSimplePath(activity) - var basePath = folder.getBasePath(activity) - var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) + val absolutePath = folder.getAbsolutePath(activity) + val storageType = folder.getStorageType(activity) + val simplePath = folder.getSimplePath(activity) + val basePath = folder.getBasePath(activity) + val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) - var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) + val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) } override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) { - Log.e(TAG, "STORAGE ACCESS DENIED") - var jsobj = JSObject() - jsobj.put("error", "Access Denied") - call.resolve(jsobj) + 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?") + 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.show() + } else { + Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") + jsobj.put("error", "Access Denied") + call.resolve(jsobj) + } } + override fun onStoragePermissionDenied(requestCode: Int) { Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") - var jsobj = JSObject() + val jsobj = JSObject() jsobj.put("error", "Permission Denied") call.resolve(jsobj) } + } - mainActivity.storage.openFolderPicker(6) + mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER) } @RequiresApi(Build.VERSION_CODES.R) diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 87c98b8c..b55910f5 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -1,7 +1,6 @@ package com.audiobookshelf.app.server import android.content.Context -import android.content.SharedPreferences import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.util.Log @@ -17,6 +16,7 @@ import com.getcapacitor.JSObject import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONException import org.json.JSONObject import java.io.IOException import java.util.concurrent.TimeUnit @@ -28,8 +28,6 @@ class ApiHandler(var ctx:Context) { private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) - var storageSharedPreferences: SharedPreferences? = null - data class LocalMediaProgressSyncPayload(val localMediaProgress:List) @JsonIgnoreProperties(ignoreUnknown = true) data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List) @@ -81,6 +79,12 @@ class ApiHandler(var ctx:Context) { return false } + fun isUsingCellularData(): Boolean { + val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true + } + fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) { val client = httpClient ?: defaultClient client.newCall(request).enqueue(object : Callback { @@ -106,14 +110,21 @@ class ApiHandler(var ctx:Context) { if (bodyString == "OK") { cb(JSObject()) } else { - var jsonObj = JSObject() - if (bodyString.startsWith("[")) { - val array = JSArray(bodyString) - jsonObj.put("value", array) - } else { - jsonObj = JSObject(bodyString) + try { + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + val array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + cb(jsonObj) + } catch(je:JSONException) { + Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString") + val jsobj = JSObject() + jsobj.put("error", "Invalid response body") + cb(jsobj) } - cb(jsonObj) } } } @@ -225,11 +236,15 @@ class ApiHandler(var ctx:Context) { } } - fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) { + fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) { val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession)) postRequest("/api/session/local", payload) { - cb() + if (!it.getString("error").isNullOrEmpty()) { + cb(false) + } else { + cb(true) + } } } diff --git a/android/app/src/main/res/drawable-nodpi/example_appwidget_preview.png b/android/app/src/main/res/drawable-nodpi/example_appwidget_preview.png new file mode 100644 index 00000000..894b069a Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/example_appwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-v21/app_widget_background.xml b/android/app/src/main/res/drawable-v21/app_widget_background.xml new file mode 100644 index 00000000..878e0414 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/app_widget_background.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml b/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml new file mode 100644 index 00000000..cd72ec57 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/new_app_widget.xml b/android/app/src/main/res/layout/new_app_widget.xml new file mode 100644 index 00000000..99ac1b46 --- /dev/null +++ b/android/app/src/main/res/layout/new_app_widget.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v21/styles.xml b/android/app/src/main/res/values-v21/styles.xml new file mode 100644 index 00000000..80d515ff --- /dev/null +++ b/android/app/src/main/res/values-v21/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml new file mode 100644 index 00000000..7781ac86 --- /dev/null +++ b/android/app/src/main/res/values/attrs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..b2bffa8d --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #FFE1F5FE + #FF81D4FA + #FF039BE5 + #FF01579B + \ No newline at end of file diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..4db8c590 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,10 @@ + + + + + 0dp + + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a99df0e9..b0944308 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -4,4 +4,6 @@ audiobookshelf com.audiobookshelf.app com.audiobookshelf.app + Add widget + Simple widget for audiobookshelf playback diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 102f1273..ccd013a3 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -19,4 +19,13 @@ + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..254f4a55 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/android/app/src/main/res/xml/new_app_widget_info.xml b/android/app/src/main/res/xml/new_app_widget_info.xml new file mode 100644 index 00000000..ffa76ee7 --- /dev/null +++ b/android/app/src/main/res/xml/new_app_widget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/assets/app.css b/assets/app.css index 98a26d1a..2bbdd034 100644 --- a/assets/app.css +++ b/assets/app.css @@ -63,6 +63,12 @@ body { box-shadow: 2px 10px 8px #1111117e; } +.altBookshelfDivider { + background: rgb(38 38 38); + /*background: linear-gradient(180deg, rgba(191, 193, 195, 1) 0%, rgb(156, 158, 159) 17%, rgb(114, 115, 117) 88%, rgb(120, 120, 122) 100%);*/ + box-shadow: 2px 10px 8px #1111117e; +} + /* Bookshelf Label */ @@ -78,6 +84,14 @@ Bookshelf Label color: #fce3a6; } +.altBookshelfLabel { + background-color: #2d3436; + background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%); + border-color: rgb(255, 255, 255); + border-style: solid; + color: #ffffff; +} + .cover-bg { width: calc(100% + 40px); height: calc(100% + 40px); diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index 21d17a4c..a2e9a8bd 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -8,25 +8,28 @@ arrow_back
-
+
-

{{ currentLibraryName }}

+

{{ currentLibraryName }}

+ + +
- cast + cast
- + search -
+
menu
diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index fbbd908d..61a94a19 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -34,6 +34,10 @@
+ +
+ error +
@@ -129,13 +133,16 @@ export default { onPlaybackClosedListener: null, onPlayingUpdateListener: null, onMetadataListener: null, + onProgressSyncFailing: null, + onProgressSyncSuccess: null, touchStartY: 0, touchStartTime: 0, touchEndY: 0, useChapterTrack: false, isLoading: false, touchTrackStart: false, - dragPercent: 0 + dragPercent: 0, + syncStatus: 0 } }, watch: { @@ -523,7 +530,6 @@ export default { var data = await AbsAudioPlayer.getCurrentTime() this.currentTime = Number(data.value.toFixed(2)) this.bufferedTime = Number(data.bufferedTime.toFixed(2)) - console.log('[AudioPlayer] Got Current Time', this.currentTime) this.timeupdate() }, 1000) }, @@ -676,6 +682,7 @@ export default { this.isEnded = false this.isLoading = true + this.syncStatus = 0 this.$store.commit('setPlayerItem', this.playbackSession) // Set track width @@ -704,6 +711,8 @@ export default { this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata) + this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) + this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) }, screenOrientationChange() { setTimeout(this.updateScreenSize, 50) @@ -717,6 +726,12 @@ export default { minimizePlayerEvt() { console.log('Minimize Player Evt') this.showFullscreen = false + }, + showProgressSyncIsFailing() { + this.syncStatus = this.$constants.SyncStatus.FAILED + }, + showProgressSyncSuccess() { + this.syncStatus = this.$constants.SyncStatus.SUCCESS } }, mounted() { @@ -752,6 +767,8 @@ export default { if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove() if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove() if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove() + if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove() + if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove() clearInterval(this.playInterval) } } diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index df90791c..0516fd98 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -30,11 +30,9 @@ export default { onSleepTimerEndedListener: null, onSleepTimerSetListener: null, onMediaPlayerChangedListener: null, - onProgressSyncFailing: null, sleepInterval: null, currentEndOfChapterTime: 0, - serverLibraryItemId: null, - syncFailedToast: null + serverLibraryItemId: null } }, watch: { @@ -255,10 +253,6 @@ export default { onMediaPlayerChanged(data) { var mediaPlayer = data.value this.$store.commit('setMediaPlayer', mediaPlayer) - }, - showProgressSyncIsFailing() { - if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast) - this.syncFailedToast = this.$toast('Progress is not being synced', { timeout: false, type: 'error' }) } }, mounted() { @@ -266,7 +260,6 @@ export default { this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) - this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) @@ -283,7 +276,6 @@ export default { if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() - if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove() // if (this.$server.socket) { // this.$server.socket.off('stream_open', this.streamOpen) diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index 28660b7c..8506812f 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -19,7 +19,7 @@
-

{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})

+

{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})

{{ $config.version }}

diff --git a/components/bookshelf/LazyBookshelf.vue b/components/bookshelf/LazyBookshelf.vue index a97f5e6a..8060af68 100644 --- a/components/bookshelf/LazyBookshelf.vue +++ b/components/bookshelf/LazyBookshelf.vue @@ -1,9 +1,9 @@
+
+ arrow_back +

Server address

@@ -149,7 +152,7 @@ export default { var payload = await this.authenticateToken() if (payload) { - this.setUserAndConnection(payload.user, payload.userDefaultLibraryId) + this.setUserAndConnection(payload) } else { this.showAuth = true } @@ -273,7 +276,8 @@ export default { this.error = 'Invalid username' return } - const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === this.serverConfig.username) + + const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === this.serverConfig.username && this.serverConfig.id !== scc.id) if (duplicateConfig) { this.error = 'Config already exists for this address and username' return @@ -285,14 +289,16 @@ export default { var payload = await this.requestServerLogin() this.processing = false if (payload) { - this.setUserAndConnection(payload.user, payload.userDefaultLibraryId) + this.setUserAndConnection(payload) } }, - async setUserAndConnection(user, userDefaultLibraryId) { + async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) { if (!user) return console.log('Successfully logged in', JSON.stringify(user)) + 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() if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { diff --git a/components/widgets/ConnectionIndicator.vue b/components/widgets/ConnectionIndicator.vue new file mode 100644 index 00000000..e58e01e1 --- /dev/null +++ b/components/widgets/ConnectionIndicator.vue @@ -0,0 +1,76 @@ + + + \ No newline at end of file diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index fcc0948b..217f5a17 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -487,12 +487,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 0.9.51; + MARKETING_VERSION = 0.9.55; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -511,12 +511,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 0.9.51; + MARKETING_VERSION = 0.9.55; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 447182a3..105aefab 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -1,5 +1,6 @@ import UIKit import Capacitor +import RealmSwift @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -8,6 +9,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. + + let configuration = Realm.Configuration( + schemaVersion: 1, + migrationBlock: { migration, oldSchemaVersion in + if (oldSchemaVersion < 1) { + NSLog("Realm schema version was \(oldSchemaVersion)") + migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in + newObject?["enableAltView"] = false + } + } + } + ) + Realm.Configuration.defaultConfiguration = configuration + return true } diff --git a/ios/App/App/plugins/AbsDatabase.m b/ios/App/App/plugins/AbsDatabase.m index e948a11a..21848adf 100644 --- a/ios/App/App/plugins/AbsDatabase.m +++ b/ios/App/App/plugins/AbsDatabase.m @@ -17,8 +17,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase", CAP_PLUGIN_METHOD(getLocalLibraryItems, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItem, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(getLocalLibraryItemByLLId, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise); ) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 9d17805c..8d35b708 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -86,18 +86,23 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItem(_ call: CAPPluginCall) { call.resolve() } - @objc func getLocalLibraryItemByLLId(_ call: CAPPluginCall) { + @objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) { call.resolve() } @objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) { call.resolve([ "value": [] ]) } + @objc func getAllLocalMediaProgress(_ call: CAPPluginCall) { + call.resolve([ "value": [] ]) + } @objc func updateDeviceSettings(_ call: CAPPluginCall) { let disableAutoRewind = call.getBool("disableAutoRewind") ?? false + let enableAltView = call.getBool("enableAltView") ?? false let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10 let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10 let settings = DeviceSettings() settings.disableAutoRewind = disableAutoRewind + settings.enableAltView = enableAltView settings.jumpBackwardsTime = jumpBackwardsTime settings.jumpForwardTime = jumpForwardTime diff --git a/ios/App/Shared/models/DeviceSettings.swift b/ios/App/Shared/models/DeviceSettings.swift index 9fafa2aa..cd823916 100644 --- a/ios/App/Shared/models/DeviceSettings.swift +++ b/ios/App/Shared/models/DeviceSettings.swift @@ -10,6 +10,7 @@ import RealmSwift class DeviceSettings: Object { @Persisted var disableAutoRewind: Bool + @Persisted var enableAltView: Bool @Persisted var jumpBackwardsTime: Int @Persisted var jumpForwardTime: Int } @@ -17,6 +18,7 @@ class DeviceSettings: Object { func getDefaultDeviceSettings() -> DeviceSettings { let settings = DeviceSettings() settings.disableAutoRewind = false + settings.enableAltView = false settings.jumpForwardTime = 10 settings.jumpBackwardsTime = 10 return settings @@ -26,6 +28,7 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary { return Database.realmQueue.sync { return [ "disableAutoRewind": settings.disableAutoRewind, + "enableAltView": settings.enableAltView, "jumpBackwardsTime": settings.jumpBackwardsTime, "jumpForwardTime": settings.jumpForwardTime ] diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index bd370d77..3384e5fa 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -11,6 +11,7 @@ class PlayerHandler { private static var player: AudioPlayer? private static var session: PlaybackSession? private static var timer: Timer? + private static var lastSyncTime:Double = 0.0 private static var _remainingSleepTime: Int? = nil public static var remainingSleepTime: Int? { @@ -128,7 +129,7 @@ class PlayerHandler { listeningTimePassedSinceLastSync += 1 } - if listeningTimePassedSinceLastSync > 3 { + if listeningTimePassedSinceLastSync >= 5 { syncProgress() } @@ -149,6 +150,15 @@ class PlayerHandler { return } + // Prevent multiple sync requests + let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime + if (lastSyncTime > 0 && timeSinceLastSync < 1) { + NSLog("syncProgress last sync time was < 1 second so not syncing") + return + } + + lastSyncTime = Date().timeIntervalSince1970 // seconds + let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync) session!.currentTime = playerCurrentTime diff --git a/layouts/default.vue b/layouts/default.vue index 327c8d9a..037ad41b 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -66,9 +66,6 @@ export default { }, currentLibraryId() { return this.$store.state.libraries.currentLibraryId - }, - isSocketConnected() { - return this.$store.state.socketConnected } }, methods: { @@ -112,7 +109,7 @@ 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}` } }).catch((error) => { + var 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' this.error = errorMsg @@ -123,7 +120,8 @@ export default { return } - const { user, userDefaultLibraryId } = authRes + const { user, userDefaultLibraryId, serverSettings } = authRes + 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() @@ -154,15 +152,9 @@ export default { // Only cancels stream if streamining not playing downloaded this.$eventBus.$emit('close-stream') }, - socketConnectionUpdate(isConnected) { - console.log('Socket connection update', isConnected) - }, socketConnectionFailed(err) { this.$toast.error('Socket connection error: ' + err.message) }, - socketInit(data) { - console.log('Socket init', data) - }, async initLibraries() { if (this.inittingLibraries) { return @@ -177,6 +169,7 @@ export default { async syncLocalMediaProgress() { if (!this.user) { console.log('[default] No need to sync local media progress - not connected to server') + this.$store.commit('setLastLocalMediaSyncResults', null) return } @@ -184,10 +177,15 @@ export default { var response = await this.$db.syncLocalMediaProgressWithServer() if (!response) { if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server') + this.$store.commit('setLastLocalMediaSyncResults', null) return } const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response if (numLocalMediaProgressForServer > 0) { + response.syncedAt = Date.now() + response.serverConfigName = this.$store.getters['user/getServerConfigName'] + this.$store.commit('setLastLocalMediaSyncResults', response) + if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) { console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`) } else { @@ -195,6 +193,7 @@ export default { } } else { console.log('[default] syncLocalMediaProgress No local media progress to sync') + this.$store.commit('setLastLocalMediaSyncResults', null) } }, async userUpdated(user) { @@ -216,9 +215,10 @@ export default { mediaProgress: prog } newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload) - } else { + } else if (!localProg) { // Check if local library item exists - var localLibraryItem = await this.$db.getLocalLibraryItemByLLId(prog.libraryItemId) + // local media progress may not exist yet if it hasn't been played + var localLibraryItem = await this.$db.getLocalLibraryItemByLId(prog.libraryItemId) if (localLibraryItem) { if (prog.episodeId) { // If episode check if local episode exists @@ -253,8 +253,6 @@ export default { } }, async mounted() { - this.$socket.on('connection-update', this.socketConnectionUpdate) - this.$socket.on('initialized', this.socketInit) this.$socket.on('user_updated', this.userUpdated) this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated) @@ -266,6 +264,8 @@ export default { await this.$store.dispatch('setupNetworkListener') + await this.$store.dispatch('globals/loadLocalMediaProgress') + if (this.$store.state.user.serverConnectionConfig) { console.log(`[default] server connection config set - call init libraries`) await this.initLibraries() @@ -276,14 +276,12 @@ export default { console.log(`[default] finished connection attempt or already connected ${!!this.user}`) await this.syncLocalMediaProgress() - this.$store.dispatch('globals/loadLocalMediaProgress') + this.loadSavedSettings() this.hasMounted = true } }, beforeDestroy() { - this.$socket.off('connection-update', this.socketConnectionUpdate) - this.$socket.off('initialized', this.socketInit) this.$socket.off('user_updated', this.userUpdated) this.$socket.off('user_media_progress_updated', this.userMediaProgressUpdated) } diff --git a/mixins/bookshelfCardsHelpers.js b/mixins/bookshelfCardsHelpers.js index 4b9583e7..7436fb25 100644 --- a/mixins/bookshelfCardsHelpers.js +++ b/mixins/bookshelfCardsHelpers.js @@ -45,7 +45,8 @@ export default { index, width: this.entityWidth, height: this.entityHeight, - bookCoverAspectRatio: this.bookCoverAspectRatio + bookCoverAspectRatio: this.bookCoverAspectRatio, + isAltViewEnabled: this.altViewEnabled } if (this.entityName === 'series-books') props.showSequence = true if (this.entityName === 'books') { @@ -54,7 +55,7 @@ export default { props.sortingIgnorePrefix = !!this.sortingIgnorePrefix } - var _this = this + // var _this = this var instance = new ComponentClass({ propsData: props, created() { diff --git a/package-lock.json b/package-lock.json index 6e8e598c..13c38711 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,12 @@ { "name": "audiobookshelf-app", - "version": "0.9.51-beta", + "version": "0.9.55-beta", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "0.9.44-beta", + "name": "audiobookshelf-app", + "version": "0.9.55-beta", "dependencies": { "@capacitor/android": "^3.4.3", "@capacitor/app": "^1.1.1", diff --git a/package.json b/package.json index d53a9e67..11e0b6f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-app", - "version": "0.9.51-beta", + "version": "0.9.55-beta", "author": "advplyr", "scripts": { "dev": "nuxt --hostname 0.0.0.0 --port 1337", diff --git a/pages/bookshelf/index.vue b/pages/bookshelf/index.vue index 513f5f55..9f27b4bf 100644 --- a/pages/bookshelf/index.vue +++ b/pages/bookshelf/index.vue @@ -1,6 +1,6 @@