mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-25 14:08:35 +02:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d91b95320 | ||
|
|
beb790ad17 | ||
|
|
073110376a | ||
|
|
16f6f3b7e9 | ||
|
|
4758f3e557 | ||
|
|
38cc2adf0f | ||
|
|
80fbbfc2ef | ||
|
|
ca45956479 | ||
|
|
3d64c42b14 | ||
|
|
97e7a12883 | ||
|
|
e4d2b646e7 | ||
|
|
a5a8f3b6ac | ||
|
|
41b0535213 | ||
|
|
a146a157b8 | ||
|
|
96fff469fc | ||
|
|
a60ff00f7e | ||
|
|
d52c326123 | ||
|
|
76882780a4 | ||
|
|
97eb19da86 | ||
|
|
4ed9d702e9 | ||
|
|
4c678836fb | ||
|
|
fd134097a1 | ||
|
|
2a87f1de28 | ||
|
|
1f60a552e5 | ||
|
|
3b34029ca2 | ||
|
|
5f6a1ef7e9 | ||
|
|
b51f65d2a4 | ||
|
|
dc9526beb3 | ||
|
|
7188089f63 | ||
|
|
db1d6da491 | ||
|
|
329a51f886 | ||
|
|
0c0badbae4 | ||
|
|
2da272c270 | ||
|
|
25ec5b3dcb | ||
|
|
d9cdc16ff9 | ||
|
|
066b72d54d | ||
|
|
e7c913643a | ||
|
|
9847ed9fcb | ||
|
|
721f812df0 | ||
|
|
a58965306a | ||
|
|
2b5377db04 | ||
|
|
7fe3849705 | ||
|
|
f998deb725 | ||
|
|
cb2aaede67 | ||
|
|
e07e7f70d6 | ||
|
|
48313b60c1 | ||
|
|
3be13025a8 | ||
|
|
505717e580 | ||
|
|
b4bb6bf81b | ||
|
|
6a8d901ce1 | ||
|
|
68f3f0e276 | ||
|
|
ddf8e66a08 | ||
|
|
5b7a492a48 | ||
|
|
9be1ee1843 | ||
|
|
b5c6acc2bc | ||
|
|
39b137c7f1 | ||
|
|
b16a7342aa | ||
|
|
3056c55d2f | ||
|
|
6b416e6fc1 | ||
|
|
e4c6093a82 | ||
|
|
3c1120ea48 | ||
|
|
a41e26e4c6 | ||
|
|
317dc366e3 | ||
|
|
cd4c280950 | ||
|
|
0ca8de5916 | ||
|
|
3840f2f344 | ||
|
|
f353a44fac | ||
|
|
a06796d9ca | ||
|
|
a69054fefa | ||
|
|
6805d7eb96 | ||
|
|
c77d0f1944 | ||
|
|
c457c8eaf7 | ||
|
|
a391db5dc2 | ||
|
|
2180e2f649 | ||
|
|
fc6d16bdd9 | ||
|
|
815529129a | ||
|
|
bad8d10a18 | ||
|
|
a4412da3ed | ||
|
|
870774b408 | ||
|
|
d7dcaa22a6 | ||
|
|
cc0b2943dd | ||
|
|
50ee0b2265 |
@@ -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 78
|
||||
versionName "0.9.48-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'
|
||||
|
||||
@@ -1,83 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"
|
||||
android:installLocation="preferExternal"
|
||||
package="com.audiobookshelf.app">
|
||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.audiobookshelf.app"
|
||||
android:installLocation="preferExternal" >
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
android:usesCleartextTraffic="true" >
|
||||
<!-- <receiver-->
|
||||
<!-- android:name=".NewAppWidget"-->
|
||||
<!-- android:exported="true" >-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
|
||||
<!-- </intent-filter>-->
|
||||
|
||||
<!-- <meta-data-->
|
||||
<!-- android:name="android.appwidget.provider"-->
|
||||
<!-- android:resource="@xml/new_app_widget_info" />-->
|
||||
<!-- </receiver>-->
|
||||
|
||||
<!--Used by Android Auto-->
|
||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@drawable/icon" />
|
||||
<!-- Used by Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
<!-- Support for Cast -->
|
||||
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
|
||||
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@drawable/icon" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="com.audiobookshelf.app.CastOptionsProvider" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name="com.audiobookshelf.app.MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask">
|
||||
android:label="@string/title_activity_main"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Register URL scheme -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="@string/custom_url_scheme" />
|
||||
</intent-filter>
|
||||
<!-- Register URL scheme -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="@string/custom_url_scheme" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".player.PlayerNotificationService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".player.PlayerNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="true" >
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.content.*
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.*
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
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.player.PlayerNotificationService
|
||||
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() {
|
||||
@@ -50,14 +53,11 @@ class MainActivity : BridgeActivity() {
|
||||
// .build())
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Log.d(tag, "onCreate")
|
||||
|
||||
// Grant full storage access for testing
|
||||
// var ss = SimpleStorage(this)
|
||||
// ss.requestFullStorageAccess()
|
||||
DbManager.initialize(applicationContext)
|
||||
|
||||
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
PERMISSIONS_ALL,
|
||||
@@ -68,8 +68,6 @@ class MainActivity : BridgeActivity() {
|
||||
registerPlugin(AbsDownloader::class.java)
|
||||
registerPlugin(AbsFileSystem::class.java)
|
||||
registerPlugin(AbsDatabase::class.java)
|
||||
|
||||
Paper.init(applicationContext)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -94,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +136,4 @@ class MainActivity : BridgeActivity() {
|
||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
||||
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
// override fun onUserInteraction() {
|
||||
// super.onUserInteraction()
|
||||
// Log.d(tag, "USER INTERACTION")
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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<in Bitmap>?) {
|
||||
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)
|
||||
}
|
||||
@@ -399,7 +399,12 @@ data class BookChapter(
|
||||
var start:Double,
|
||||
var end:Double,
|
||||
var title:String?
|
||||
)
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val startMs get() = (start * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endMs get() = (end * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgress(
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import io.paperdb.Paper
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
class DbManager {
|
||||
val tag = "DbManager"
|
||||
|
||||
companion object {
|
||||
var isDbInitialized = false
|
||||
|
||||
fun initialize(ctx: Context) {
|
||||
if (isDbInitialized) return
|
||||
Paper.init(ctx)
|
||||
isDbInitialized = true
|
||||
Log.i("DbManager", "Initialized Paper db")
|
||||
}
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null)
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
||||
}
|
||||
fun saveDeviceData(deviceData:DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
@@ -34,7 +45,7 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? {
|
||||
fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
|
||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||
}
|
||||
|
||||
@@ -147,6 +158,7 @@ class DbManager {
|
||||
|
||||
// Check local files
|
||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||
|
||||
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}")
|
||||
@@ -201,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) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import java.util.*
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
@@ -13,13 +12,39 @@ data class ServerConnectionConfig(
|
||||
var address:String,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String
|
||||
var token:String,
|
||||
var customHeaders:Map<String, String>?
|
||||
)
|
||||
|
||||
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(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = 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 currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
|
||||
var deviceSettings:DeviceSettings?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig():ServerConnectionConfig? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,15 +67,21 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackIndex():Int {
|
||||
for (i in 0..(audioTracks.size - 1)) {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
|
||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||
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 getCurrentTrackTimeMs():Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -26,4 +31,9 @@ object DeviceManager {
|
||||
fun getBase64Id(id:String):String {
|
||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ class FolderScanner(var ctx: Context) {
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
|
||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
@@ -157,11 +157,11 @@ class FolderScanner(var ctx: Context) {
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
} else {
|
||||
// Create new audio track
|
||||
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
||||
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
||||
audioTrackToAdd = track
|
||||
}
|
||||
|
||||
startOffset += audioProbeResult.duration
|
||||
startOffset += audioProbeResult?.duration ?: 0.0
|
||||
isNewOrUpdated = true
|
||||
} else {
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
@@ -248,7 +248,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
||||
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
@@ -289,7 +289,7 @@ class FolderScanner(var ctx: Context) {
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
|
||||
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}")
|
||||
@@ -378,7 +378,7 @@ class FolderScanner(var ctx: Context) {
|
||||
var wasUpdated = false
|
||||
|
||||
// Search for files in media item folder
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||
|
||||
filesFound.forEach {
|
||||
@@ -431,7 +431,7 @@ class FolderScanner(var ctx: Context) {
|
||||
// Create new audio track
|
||||
val lastTrack = existingAudioTracks.lastOrNull()
|
||||
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
||||
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
||||
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
||||
localLibraryItem.media.addAudioTrack(track)
|
||||
Log.d(tag, "Added New Audio Track ${track.title}")
|
||||
wasUpdated = true
|
||||
@@ -463,12 +463,18 @@ class FolderScanner(var ctx: Context) {
|
||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
||||
}
|
||||
|
||||
fun probeAudioFile(absolutePath:String):AudioProbeResult {
|
||||
var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||
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")
|
||||
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
|
||||
|
||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
return audioProbeResult
|
||||
val probeObject = JSObject(session.output)
|
||||
if (!probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath is empty")
|
||||
return 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +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 io.paperdb.Paper
|
||||
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<LibraryItem>()
|
||||
var serverLibraryItems = mutableListOf<LibraryItem>()
|
||||
var selectedLibraryId = ""
|
||||
|
||||
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
|
||||
@@ -27,15 +29,35 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
var serverLibraries = listOf<Library>()
|
||||
var serverConfigIdUsed:String? = null
|
||||
|
||||
fun initializeAndroidAuto() {
|
||||
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
|
||||
Paper.init(ctx)
|
||||
}
|
||||
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
|
||||
@@ -45,7 +67,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
serverPodcastEpisodes = listOf()
|
||||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
selectedLibraryId = ""
|
||||
}
|
||||
}
|
||||
@@ -69,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)
|
||||
}
|
||||
}
|
||||
@@ -173,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
|
||||
}
|
||||
}
|
||||
@@ -210,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
|
||||
@@ -270,16 +313,19 @@ 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) {
|
||||
cb(it)
|
||||
}
|
||||
}
|
||||
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
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 {
|
||||
|
||||
@@ -21,9 +21,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
var currentIconUri: Uri? = null
|
||||
var currentBitmap: Bitmap? = null
|
||||
|
||||
private val glideOptions = RequestOptions()
|
||||
.fallback(R.drawable.icon)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,7 +88,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
private var currentMediaItemIndex = 0
|
||||
private var pendingMediaItemRemovalPosition:PositionInfo? = null
|
||||
private var pendingSeekCount = 0
|
||||
private var pendingSeekWindowIndex = 0
|
||||
private var pendingSeekWindowIndex = C.INDEX_UNSET
|
||||
private var pendingSeekPositionMs = 0L
|
||||
|
||||
init {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.*
|
||||
@@ -25,9 +27,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it,true,null)
|
||||
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, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,9 +53,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
Log.d(tag, "ON PLAY FROM SEARCH $query")
|
||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it,true,null)
|
||||
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, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,9 +115,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
libraryItemWrapper?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it,true,null)
|
||||
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, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,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<KeyEvent>(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}")
|
||||
@@ -205,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) {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
+21
-8
@@ -31,8 +31,13 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
Log.d(tag, "ON PREPARE $playWhenReady")
|
||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it,playWhenReady,null)
|
||||
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, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,9 +59,13 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
|
||||
libraryItemWrapper?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it,playWhenReady,null)
|
||||
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, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,9 +75,13 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it,playWhenReady,null)
|
||||
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, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.audiobookshelf.app.player
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.PlayerState
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
const val PAUSE_LEN_BEFORE_RECHECK = 60000 // 1 minute
|
||||
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
|
||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||
var tag = "PlayerListener"
|
||||
@@ -23,7 +24,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
||||
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
||||
|
||||
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
|
||||
@@ -69,26 +70,37 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
if (player.isPlaying) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0) {
|
||||
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) {
|
||||
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500
|
||||
Log.d(tag, "SeekBackTime $backTime")
|
||||
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
|
||||
playerNotificationService.seekBackward(backTime)
|
||||
} else {
|
||||
Log.d(tag, "SeekBackTime: back time is 0")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if playback session still exists or sync media progress if updated
|
||||
if (lastPauseTime > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress()
|
||||
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")
|
||||
@@ -96,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +127,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
if (lastPauseTime <= 0) return 0
|
||||
val time: Long = System.currentTimeMillis() - lastPauseTime
|
||||
val seekback: Long
|
||||
if (time < 3000) seekback = 0
|
||||
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s
|
||||
if (time < 10000) seekback = 0 // 10s or less = no seekback
|
||||
else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s
|
||||
else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s
|
||||
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
|
||||
else seekback = 29500 // 30m and up = jump back 30s
|
||||
return seekback
|
||||
|
||||
@@ -14,6 +14,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
||||
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
PlayerNotificationService.isClosed = false
|
||||
playerNotificationService.startForeground(notificationId, notification)
|
||||
}
|
||||
|
||||
@@ -26,6 +27,12 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
||||
playerNotificationService.stopSelf()
|
||||
} else {
|
||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||
|
||||
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
|
||||
if (!PlayerNotificationService.isClosed) {
|
||||
Log.d(tag, "PNS is not closed - closing it now")
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+170
-49
@@ -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
|
||||
@@ -41,6 +48,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
companion object {
|
||||
var isStarted = false
|
||||
var isClosed = false
|
||||
var isUnmeteredNetwork = false
|
||||
}
|
||||
|
||||
interface ClientEventEmitter {
|
||||
@@ -54,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"
|
||||
@@ -64,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
|
||||
@@ -112,11 +123,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
fun getService(): PlayerNotificationService = this@PlayerNotificationService
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
val stopIntent = Intent(context, PlayerNotificationService::class.java)
|
||||
context.stopService(stopIntent)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
isStarted = true
|
||||
Log.d(tag, "onStartCommand $startId")
|
||||
@@ -159,30 +165,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
Log.d(tag, "onCreate")
|
||||
super.onCreate()
|
||||
ctx = this
|
||||
|
||||
// Initialize player
|
||||
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
|
||||
1000 * 20, // 20s min buffer
|
||||
1000 * 45, // 45s max buffer
|
||||
1000 * 5, // 5s playback start
|
||||
1000 * 20 // 20s playback rebuffer
|
||||
).build()
|
||||
|
||||
mPlayer = ExoPlayer.Builder(this)
|
||||
.setLoadControl(customLoadControl)
|
||||
.setSeekBackIncrementMs(10000)
|
||||
.setSeekForwardIncrementMs(10000)
|
||||
// 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()
|
||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||
mPlayer.addListener(PlayerListener(this))
|
||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
||||
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
|
||||
|
||||
currentPlayer = mPlayer
|
||||
DbManager.initialize(ctx)
|
||||
|
||||
// Initialize API
|
||||
apiHandler = ApiHandler(ctx)
|
||||
@@ -213,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)
|
||||
@@ -234,6 +232,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playerNotificationManager.setUseNextAction(false)
|
||||
playerNotificationManager.setUsePreviousAction(false)
|
||||
playerNotificationManager.setUseChronometer(false)
|
||||
playerNotificationManager.setUseStopAction(true)
|
||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||
@@ -247,6 +246,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
|
||||
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
||||
if (currentPlaybackSession == null) {
|
||||
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
|
||||
return MediaDescriptionCompat.Builder().build()
|
||||
}
|
||||
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
||||
|
||||
// Fix for local images crashing on Android 11 for specific devices
|
||||
@@ -276,17 +280,66 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
)
|
||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||
mediaSessionConnector.setPlayer(mPlayer)
|
||||
|
||||
// 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()
|
||||
currentPlayer = mPlayer
|
||||
}
|
||||
|
||||
private fun initializeMPlayer() {
|
||||
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
|
||||
1000 * 20, // 20s min buffer
|
||||
1000 * 45, // 45s max buffer
|
||||
1000 * 5, // 5s playback start
|
||||
1000 * 20 // 20s playback rebuffer
|
||||
).build()
|
||||
|
||||
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
|
||||
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
|
||||
Log.d(tag, "Seek Back Time $seekBackTime")
|
||||
Log.d(tag, "Seek Forward Time $seekForwardTime")
|
||||
|
||||
mPlayer = ExoPlayer.Builder(this)
|
||||
.setLoadControl(customLoadControl)
|
||||
.setSeekBackIncrementMs(seekBackTime)
|
||||
.setSeekForwardIncrementMs(seekForwardTime)
|
||||
.build()
|
||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||
mPlayer.addListener(PlayerListener(this))
|
||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
||||
|
||||
//attach player to playerNotificationManager
|
||||
playerNotificationManager.setPlayer(mPlayer)
|
||||
mediaSession.setCallback(MediaSessionCallback(this))
|
||||
|
||||
mediaSessionConnector.setPlayer(mPlayer)
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
|
||||
@@ -381,8 +434,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||
val episodeId = playbackSession.episodeId
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
if (it == null) { // Play request failed
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback()
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -400,8 +458,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||
val episodeId = playbackSession.episodeId
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to start new playback session")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,34 +560,59 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return currentPlaybackSession?.id
|
||||
}
|
||||
|
||||
fun getCurrentBookChapter():BookChapter? {
|
||||
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if (playbackSession.isLocal) {
|
||||
|
||||
// Make sure this connection config exists
|
||||
val serverConnectionConfig = DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId)
|
||||
if (serverConnectionConfig == null) {
|
||||
Log.d(tag, "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}")
|
||||
return true // carry on
|
||||
}
|
||||
|
||||
// Local playback session check if server has updated media progress
|
||||
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
|
||||
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId) { mediaProgress ->
|
||||
if (mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
|
||||
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId, serverConnectionConfig) { mediaProgress ->
|
||||
|
||||
if (mediaProgress != null && mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
|
||||
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
|
||||
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
|
||||
|
||||
// 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 {
|
||||
@@ -548,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)
|
||||
@@ -611,11 +702,27 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
fun closePlayback() {
|
||||
currentPlayer.clearMediaItems()
|
||||
currentPlayer.stop()
|
||||
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()
|
||||
} catch(e:Exception) {
|
||||
Log.e(tag, "Exception clearing exoplayer $e")
|
||||
}
|
||||
|
||||
currentPlaybackSession = null
|
||||
clientEventEmitter?.onPlaybackClosed()
|
||||
PlayerListener.lastPauseTime = 0
|
||||
isClosed = true
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
fun sendClientMetadata(playerState: PlayerState) {
|
||||
@@ -650,6 +757,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
clientEventEmitter?.onProgressSyncFailing()
|
||||
}
|
||||
|
||||
fun alertSyncSuccess() {
|
||||
clientEventEmitter?.onProgressSyncSuccess()
|
||||
}
|
||||
|
||||
//
|
||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||
//
|
||||
@@ -658,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<String>(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__"
|
||||
@@ -683,11 +794,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
// No further calls will be made to other media browsing methods.
|
||||
null
|
||||
} else {
|
||||
if (!isStarted) {
|
||||
Log.d(tag, "AA Not yet started")
|
||||
mediaManager.initializeAndroidAuto()
|
||||
isStarted = true
|
||||
}
|
||||
isStarted = true
|
||||
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
|
||||
|
||||
isAndroidAuto = true
|
||||
@@ -823,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -165,7 +163,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
return call.resolve()
|
||||
return call.resolve(JSObject("{\"error\":\"Invalid request\"}"))
|
||||
}
|
||||
|
||||
if (libraryItemId.startsWith("local")) { // Play local media item
|
||||
@@ -176,14 +174,28 @@ class AbsAudioPlayer : Plugin() {
|
||||
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
|
||||
if (episode == null) {
|
||||
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
|
||||
return call.resolve(JSObject())
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -191,13 +203,27 @@ class AbsAudioPlayer : Plugin() {
|
||||
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 TEST ${jacksonMapper.writeValueAsString(it)}")
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class AbsDatabase : Plugin() {
|
||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
@@ -37,7 +38,7 @@ class AbsDatabase : Plugin() {
|
||||
@PluginMethod
|
||||
fun getDeviceData(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var deviceData = DeviceManager.dbManager.getDeviceData()
|
||||
val deviceData = DeviceManager.dbManager.getDeviceData()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
|
||||
}
|
||||
}
|
||||
@@ -45,17 +46,17 @@ class AbsDatabase : Plugin() {
|
||||
@PluginMethod
|
||||
fun getLocalFolders(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var folders = DeviceManager.dbManager.getAllLocalFolders()
|
||||
val folders = DeviceManager.dbManager.getAllLocalFolders()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalFolder(call:PluginCall) {
|
||||
var folderId = call.getString("folderId", "").toString()
|
||||
val folderId = call.getString("folderId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
|
||||
var folderObj = jacksonMapper.writeValueAsString(it)
|
||||
val folderObj = jacksonMapper.writeValueAsString(it)
|
||||
call.resolve(JSObject(folderObj))
|
||||
} ?: call.resolve()
|
||||
}
|
||||
@@ -63,10 +64,10 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItem(call:PluginCall) {
|
||||
var id = call.getString("id", "").toString()
|
||||
val id = call.getString("id", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
@@ -76,10 +77,10 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItemByLLId(call:PluginCall) {
|
||||
var libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
fun getLocalLibraryItemByLId(call:PluginCall) {
|
||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
@@ -90,40 +91,41 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItems(call:PluginCall) {
|
||||
var mediaType = call.getString("mediaType", "").toString()
|
||||
val mediaType = call.getString("mediaType", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
|
||||
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItemsInFolder(call:PluginCall) {
|
||||
var folderId = call.getString("folderId", "").toString()
|
||||
val folderId = call.getString("folderId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
||||
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||
var serverConnectionConfigId = call.getString("id", "").toString()
|
||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||
|
||||
var userId = call.getString("userId", "").toString()
|
||||
var username = call.getString("username", "").toString()
|
||||
var token = call.getString("token", "").toString()
|
||||
val userId = serverConfigPayload.userId
|
||||
val username = serverConfigPayload.username
|
||||
val token = serverConfigPayload.token
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (serverConnectionConfig == null) { // New Server Connection
|
||||
var serverAddress = call.getString("address", "").toString()
|
||||
val serverAddress = call.getString("address", "").toString()
|
||||
|
||||
// Create new server connection config
|
||||
var sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token)
|
||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
||||
|
||||
// Add and save
|
||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||
@@ -140,8 +142,8 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
|
||||
// Set last connection config
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ class AbsDatabase : Plugin() {
|
||||
@PluginMethod
|
||||
fun removeServerConnectionConfig(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
@@ -182,7 +184,7 @@ class AbsDatabase : Plugin() {
|
||||
@PluginMethod
|
||||
fun getAllLocalMediaProgress(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
||||
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
|
||||
}
|
||||
}
|
||||
@@ -331,13 +333,13 @@ class AbsDatabase : Plugin() {
|
||||
// Send update to server media progress is linked to a server and user is logged into that server
|
||||
localMediaProgress.serverConnectionConfigId?.let { configId ->
|
||||
if (DeviceManager.serverConnectionConfigId == configId) {
|
||||
var libraryItemId = localMediaProgress.libraryItemId ?: ""
|
||||
var episodeId = localMediaProgress.episodeId ?: ""
|
||||
var updatePayload = JSObject()
|
||||
val libraryItemId = localMediaProgress.libraryItemId ?: ""
|
||||
val episodeId = localMediaProgress.episodeId ?: ""
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("isFinished", isFinished)
|
||||
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
||||
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
||||
var jsobj = JSObject()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("local", true)
|
||||
jsobj.put("server", true)
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
@@ -346,7 +348,7 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
|
||||
var jsobj = JSObject()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("local", true)
|
||||
jsobj.put("server", false)
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
@@ -356,25 +358,25 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalTrackOrder(call:PluginCall) {
|
||||
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
|
||||
var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
|
||||
val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
|
||||
|
||||
var tracks:JSArray = call.getArray("tracks") ?: JSArray()
|
||||
val tracks:JSArray = call.getArray("tracks") ?: JSArray()
|
||||
Log.d(tag, "updateLocalTrackOrder $tracks")
|
||||
|
||||
var index = 1
|
||||
var hasUpdates = false
|
||||
for (i in 0 until tracks.length()) {
|
||||
var track = tracks.getJSONObject(i)
|
||||
var localFileId = track.getString("localFileId")
|
||||
val track = tracks.getJSONObject(i)
|
||||
val localFileId = track.getString("localFileId")
|
||||
|
||||
var existingTrack = audioTracks.find{ it.localFileId == localFileId }
|
||||
val existingTrack = audioTracks.find{ it.localFileId == localFileId }
|
||||
if (existingTrack != null) {
|
||||
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
|
||||
if (existingTrack.index != index) hasUpdates = true
|
||||
@@ -394,4 +396,15 @@ class AbsDatabase : Plugin() {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,31 +143,35 @@ class AbsDownloader : Plugin() {
|
||||
}
|
||||
|
||||
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
if (libraryItem == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder != null) {
|
||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder != null) {
|
||||
|
||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
} else if (episodeId.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val episode = podcast.episodes?.find { podcastEpisode ->
|
||||
podcastEpisode.id == episodeId
|
||||
}
|
||||
if (episode == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
} else if (episodeId.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val episode = podcast.episodes?.find { podcastEpisode ->
|
||||
podcastEpisode.id == episodeId
|
||||
}
|
||||
if (episode == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, episode)
|
||||
call.resolve()
|
||||
}
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, episode)
|
||||
startLibraryItemDownload(libraryItem, localFolder, null)
|
||||
call.resolve()
|
||||
}
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, null)
|
||||
call.resolve()
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
}
|
||||
} else {
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,9 +401,13 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
|
||||
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb)
|
||||
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..
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<LocalMediaProgress>)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
|
||||
@@ -81,19 +79,27 @@ 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 {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb(JSObject())
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Failed to connect")
|
||||
cb(jsobj)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
// throw IOException("Unexpected code $response")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Unexpected code $response")
|
||||
cb(jsobj)
|
||||
@@ -104,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,19 +146,29 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
|
||||
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
|
||||
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||
cb(libraryItem)
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getLibraryItem Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
|
||||
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) {
|
||||
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
|
||||
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
|
||||
getRequest(requestUrl, null, null) {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||
cb(libraryItem)
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,15 +207,20 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession) -> Unit) {
|
||||
fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
|
||||
|
||||
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
||||
postRequest(endpoint, payload) {
|
||||
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
|
||||
it.put("serverAddress", DeviceManager.serverAddress)
|
||||
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
|
||||
cb(playbackSession)
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
|
||||
it.put("serverAddress", DeviceManager.serverAddress)
|
||||
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
|
||||
cb(playbackSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,10 +266,12 @@ class ApiHandler(var ctx:Context) {
|
||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
||||
postRequest("/api/me/sync-local-progress", payload) {
|
||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
|
||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
||||
|
||||
if (it.toString() == "{}") {
|
||||
Log.e(tag, "Progress sync received empty object")
|
||||
} else if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Progress sync error")
|
||||
} else {
|
||||
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
||||
|
||||
@@ -269,11 +303,18 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaProgress(libraryItemId:String, episodeId:String?, cb: (MediaProgress) -> Unit) {
|
||||
fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) {
|
||||
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||
getRequest(endpoint, null, null) {
|
||||
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
|
||||
cb(progress)
|
||||
|
||||
// TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
|
||||
getRequest(endpoint, pingClient, serverConnectionConfig) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, "getMediaProgress: Failed to get progress")
|
||||
cb(null)
|
||||
} else {
|
||||
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
|
||||
cb(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,5 @@
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -11,11 +11,5 @@
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Background for widgets to make the rounded corners based on the
|
||||
appWidgetRadius attribute value
|
||||
-->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="?attr/appWidgetRadius" />
|
||||
<solid android:color="?android:attr/colorBackground" />
|
||||
</shape>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Background for views inside widgets to make the rounded corners based on the
|
||||
appWidgetInnerRadius attribute value
|
||||
-->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="?attr/appWidgetInnerRadius" />
|
||||
<solid android:color="?android:attr/colorPrimaryDark" />
|
||||
</shape>
|
||||
@@ -2,6 +2,4 @@
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
||||
</vector>
|
||||
|
||||
@@ -2,6 +2,4 @@
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
style="@style/Widget.Android.AppWidget.Container"
|
||||
android:id="@+id/appWidget"
|
||||
android:theme="@style/AppTheme.AppWidgetContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_centerInParent="true"
|
||||
android:adjustViewBounds="true"
|
||||
android:alpha="0.75"
|
||||
android:contentDescription="Cover image"
|
||||
android:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/playPauseIcon"
|
||||
android:layout_width="71dp"
|
||||
android:layout_height="55dp"
|
||||
android:layout_centerInParent="true"
|
||||
app:srcCompat="@drawable/cast_ic_expanded_controller_play" />
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
<resources>
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||
<item name="android:background">@drawable/app_widget_background</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||
<item name="android:background">@drawable/app_widget_inner_view_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<declare-styleable name="AppWidgetAttrs">
|
||||
<attr name="appWidgetPadding" format="dimension" />
|
||||
<attr name="appWidgetInnerRadius" format="dimension" />
|
||||
<attr name="appWidgetRadius" format="dimension" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<color name="light_blue_50">#FFE1F5FE</color>
|
||||
<color name="light_blue_200">#FF81D4FA</color>
|
||||
<color name="light_blue_600">#FF039BE5</color>
|
||||
<color name="light_blue_900">#FF01579B</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!--
|
||||
Refer to App Widget Documentation for margin information
|
||||
http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
|
||||
-->
|
||||
<dimen name="widget_margin">0dp</dimen>
|
||||
|
||||
</resources>
|
||||
@@ -4,4 +4,6 @@
|
||||
<string name="title_activity_main">audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||
</resources>
|
||||
|
||||
@@ -19,4 +19,13 @@
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:background">@drawable/screen</item>
|
||||
</style>
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:id">@android:id/background</item>
|
||||
<item name="android:background">?android:attr/colorBackground</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||
<item name="android:background">?android:attr/colorBackground</item>
|
||||
<item name="android:textColor">?android:attr/textColorPrimary</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<resources>
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
|
||||
<!-- Radius of the outer bound of widgets to make the rounded corners -->
|
||||
<item name="appWidgetRadius">16dp</item>
|
||||
<!--
|
||||
Radius of the inner view's bound of widgets to make the rounded corners.
|
||||
It needs to be 8dp or less than the value of appWidgetRadius
|
||||
-->
|
||||
<item name="appWidgetInnerRadius">8dp</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.AppWidgetContainer"
|
||||
parent="AppTheme.AppWidgetContainerParent">
|
||||
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
|
||||
<item name="appWidgetPadding">16dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="40dp"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:previewImage="@drawable/example_appwidget_preview"
|
||||
android:initialLayout="@layout/new_app_widget"
|
||||
android:description="@string/app_widget_description"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:initialKeyguardLayout="@layout/new_app_widget"
|
||||
/>
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "./fonts.css";
|
||||
@import './defaultStyles.css';
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
@@ -62,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
|
||||
*/
|
||||
@@ -77,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);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
|
||||
This is for setting regular html styles for places where embedding HTML will be
|
||||
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
|
||||
|
||||
*/
|
||||
|
||||
.default-style p {
|
||||
display: block;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.default-style a {
|
||||
text-decoration: none;
|
||||
color: #5985ff;
|
||||
}
|
||||
|
||||
.default-style ul {
|
||||
display: block;
|
||||
list-style: circle;
|
||||
list-style-type: disc;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style ol {
|
||||
display: block;
|
||||
list-style: decimal;
|
||||
list-style-type: decimal;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style li {
|
||||
display: list-item;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
.default-style li::marker {
|
||||
unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-transform: none;
|
||||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
@@ -8,25 +8,28 @@
|
||||
<span class="material-icons text-3xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="user && currentLibrary">
|
||||
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" :size="4" />
|
||||
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p>
|
||||
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<widgets-connection-indicator />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<widgets-download-progress-indicator />
|
||||
|
||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
|
||||
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span>
|
||||
<span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="user" class="h-7 mx-2" to="/search">
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" to="/search">
|
||||
<span class="material-icons" style="font-size: 1.75rem">search</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="h-7 mx-2">
|
||||
<div class="h-7 mx-1.5">
|
||||
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+103
-37
@@ -30,13 +30,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</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">
|
||||
<span class="material-icons text-error text-3xl">error</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden">
|
||||
<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>
|
||||
</div>
|
||||
@@ -63,12 +67,12 @@
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="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>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,6 +113,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
windowHeight: 0,
|
||||
playbackSession: null,
|
||||
showChapterModal: false,
|
||||
showFullscreen: false,
|
||||
@@ -122,20 +127,28 @@ export default {
|
||||
isEnded: false,
|
||||
volume: 0.5,
|
||||
readyTrackWidth: 0,
|
||||
playedTrackWidth: 0,
|
||||
seekedTime: 0,
|
||||
seekLoading: false,
|
||||
onPlaybackSessionListener: null,
|
||||
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: {
|
||||
showFullscreen(val) {
|
||||
this.updateScreenSize()
|
||||
this.$store.commit('setPlayerFullscreen', !!val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -153,14 +166,31 @@ export default {
|
||||
})
|
||||
return items
|
||||
},
|
||||
jumpForwardIcon() {
|
||||
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
|
||||
},
|
||||
jumpBackwardsIcon() {
|
||||
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
|
||||
},
|
||||
jumpForwardTime() {
|
||||
return this.$store.getters['getJumpForwardTime']
|
||||
},
|
||||
jumpBackwardsTime() {
|
||||
return this.$store.getters['getJumpBackwardsTime']
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
||||
return 60
|
||||
},
|
||||
fullscreenBookCoverWidth() {
|
||||
var heightScale = (this.windowHeight - 200) / 651
|
||||
if (this.bookCoverAspectRatio === 1) {
|
||||
return this.showFullscreen ? 260 : 60
|
||||
return 260 * heightScale
|
||||
}
|
||||
return this.showFullscreen ? 200 : 60
|
||||
return 200 * heightScale
|
||||
},
|
||||
showCastBtn() {
|
||||
return this.$store.state.isCastAvailable
|
||||
@@ -271,6 +301,14 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickTitleAndAuthor() {
|
||||
if (!this.showFullscreen) return
|
||||
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
||||
if (llid) {
|
||||
this.$router.push(`/item/${llid}`)
|
||||
this.showFullscreen = false
|
||||
}
|
||||
},
|
||||
touchstartTrack(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
|
||||
this.touchTrackStart = true
|
||||
@@ -331,13 +369,13 @@ export default {
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
backward10() {
|
||||
jumpBackwards() {
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekBackward({ value: 10 })
|
||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||
},
|
||||
forward10() {
|
||||
jumpForward() {
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekForward({ value: 10 })
|
||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
@@ -416,15 +454,11 @@ export default {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
}
|
||||
var ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
@@ -447,7 +481,6 @@ export default {
|
||||
var perc = time / this.totalDuration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
|
||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||
@@ -497,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)
|
||||
},
|
||||
@@ -650,6 +682,7 @@ export default {
|
||||
|
||||
this.isEnded = false
|
||||
this.isLoading = true
|
||||
this.syncStatus = 0
|
||||
this.$store.commit('setPlayerItem', this.playbackSession)
|
||||
|
||||
// Set track width
|
||||
@@ -662,7 +695,6 @@ export default {
|
||||
})
|
||||
},
|
||||
onPlaybackClosed() {
|
||||
console.log('Received onPlaybackClosed evt')
|
||||
this.endPlayback()
|
||||
},
|
||||
onPlaybackFailed(data) {
|
||||
@@ -679,21 +711,53 @@ 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)
|
||||
},
|
||||
updateScreenSize() {
|
||||
this.windowHeight = window.innerHeight
|
||||
var coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
console.log('Minimize Player Evt')
|
||||
this.showFullscreen = false
|
||||
},
|
||||
showProgressSyncIsFailing() {
|
||||
this.syncStatus = this.$constants.SyncStatus.FAILED
|
||||
},
|
||||
showProgressSyncSuccess() {
|
||||
this.syncStatus = this.$constants.SyncStatus.SUCCESS
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateScreenSize()
|
||||
if (screen.orientation) {
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
}
|
||||
|
||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
document.body.addEventListener('touchmove', this.touchmove)
|
||||
this.$nextTick(this.init)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (screen.orientation) {
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
}
|
||||
|
||||
if (this.playbackSession) {
|
||||
console.log('[AudioPlayer] Before destroy closing playback')
|
||||
this.closePlayback()
|
||||
}
|
||||
|
||||
this.forceCloseDropdownMenu()
|
||||
this.$eventBus.$off('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.removeEventListener('touchstart', this.touchstart)
|
||||
document.body.removeEventListener('touchend', this.touchend)
|
||||
document.body.removeEventListener('touchmove', this.touchmove)
|
||||
@@ -703,12 +767,20 @@ 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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--cover-image-width: 0px;
|
||||
--cover-image-height: 0px;
|
||||
--cover-image-width-collapsed: 60px;
|
||||
--cover-image-height-collapsed: 60px;
|
||||
}
|
||||
.bookCoverWrapper {
|
||||
box-shadow: 3px -2px 5px #00000066;
|
||||
}
|
||||
@@ -739,15 +811,12 @@ export default {
|
||||
.cover-wrapper {
|
||||
bottom: 44px;
|
||||
left: 12px;
|
||||
height: 96px;
|
||||
width: 60px;
|
||||
height: var(--cover-image-height-collapsed);
|
||||
width: var(--cover-image-width-collapsed);
|
||||
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: left, bottom, width, height;
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
.cover-wrapper.square-cover {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.total-track {
|
||||
bottom: 215px;
|
||||
@@ -779,16 +848,18 @@ export default {
|
||||
}
|
||||
|
||||
.fullscreen .title-author-texts {
|
||||
bottom: 36%;
|
||||
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
||||
width: 80%;
|
||||
left: 10%;
|
||||
text-align: center;
|
||||
padding-bottom: calc(((260px - var(--cover-image-height)) / 260) * 40);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.fullscreen .title-author-texts .title-text {
|
||||
font-size: 1.2rem;
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.2rem);
|
||||
}
|
||||
.fullscreen .title-author-texts .author-text {
|
||||
font-size: 1rem;
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
|
||||
}
|
||||
|
||||
#playerControls {
|
||||
@@ -826,16 +897,11 @@ export default {
|
||||
}
|
||||
|
||||
.fullscreen .cover-wrapper {
|
||||
bottom: 46%;
|
||||
left: calc(50% - 100px);
|
||||
margin: 0 auto;
|
||||
height: 320px;
|
||||
width: 200px;
|
||||
}
|
||||
.fullscreen .cover-wrapper.square-cover {
|
||||
height: 260px;
|
||||
width: 260px;
|
||||
left: calc(50% - 130px);
|
||||
height: var(--cover-image-height);
|
||||
width: var(--cover-image-width);
|
||||
left: calc(50% - (calc(var(--cover-image-width)) / 2));
|
||||
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
|
||||
}
|
||||
|
||||
.fullscreen #playerControls {
|
||||
|
||||
@@ -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: {
|
||||
@@ -185,11 +183,17 @@ export default {
|
||||
}
|
||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
|
||||
.then((data) => {
|
||||
console.log('Library item play response', JSON.stringify(data))
|
||||
AbsAudioPlayer.requestSession()
|
||||
if (data.error) {
|
||||
const errorMsg = data.error || 'Failed to play'
|
||||
this.$toast.error(errorMsg)
|
||||
} else {
|
||||
console.log('Library item play response', JSON.stringify(data))
|
||||
AbsAudioPlayer.requestSession()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to play')
|
||||
})
|
||||
},
|
||||
async playLibraryItem(payload) {
|
||||
@@ -220,15 +224,21 @@ export default {
|
||||
console.log('Called playLibraryItem', libraryItemId)
|
||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
|
||||
.then((data) => {
|
||||
console.log('Library item play response', JSON.stringify(data))
|
||||
if (!libraryItemId.startsWith('local')) {
|
||||
this.serverLibraryItemId = libraryItemId
|
||||
if (data.error) {
|
||||
const errorMsg = data.error || 'Failed to play'
|
||||
this.$toast.error(errorMsg)
|
||||
} else {
|
||||
this.serverLibraryItemId = serverLibraryItemId
|
||||
console.log('Library item play response', JSON.stringify(data))
|
||||
if (!libraryItemId.startsWith('local')) {
|
||||
this.serverLibraryItemId = libraryItemId
|
||||
} else {
|
||||
this.serverLibraryItemId = serverLibraryItemId
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to play')
|
||||
})
|
||||
},
|
||||
pauseItem() {
|
||||
@@ -243,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() {
|
||||
@@ -254,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}`)
|
||||
@@ -271,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)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
|
||||
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
|
||||
<p class="text-xs">{{ serverConnectionConfig.address }}</p>
|
||||
<p class="text-xs text-gray-400" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<p class="text-xs">{{ $config.version }}</p>
|
||||
@@ -71,6 +71,9 @@ export default {
|
||||
serverConnectionConfig() {
|
||||
return this.$store.state.user.serverConnectionConfig
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings || {}
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : ''
|
||||
},
|
||||
@@ -105,12 +108,12 @@ export default {
|
||||
text: 'Local Media',
|
||||
to: '/localMedia/folders'
|
||||
})
|
||||
// items.push({
|
||||
// icon: 'settings',
|
||||
// text: 'Settings',
|
||||
// to: '/settings'
|
||||
// })
|
||||
}
|
||||
items.push({
|
||||
icon: 'settings',
|
||||
text: 'Settings',
|
||||
to: '/settings'
|
||||
})
|
||||
return items
|
||||
},
|
||||
currentRoutePath() {
|
||||
@@ -122,11 +125,15 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
this.$socket.logout()
|
||||
await this.$db.logout()
|
||||
this.$localStore.removeLastLibraryId()
|
||||
this.$store.commit('user/logout')
|
||||
this.$router.push('/connect')
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div id="bookshelf" class="w-full max-w-full h-full">
|
||||
<template v-for="shelf in totalShelves">
|
||||
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
|
||||
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
|
||||
<div v-else class="flex border-t border-white border-opacity-10" />
|
||||
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView || altViewEnabled ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
|
||||
<div v-if="!showBookshelfListView && !altViewEnabled" class="w-full absolute bottom-0 left-0 z-30 bookshelfDivider" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
|
||||
<div v-else-if="showBookshelfListView" class="flex border-t border-white border-opacity-10" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -119,12 +119,23 @@ export default {
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.showBookshelfListView) return this.entityHeight + 16
|
||||
if (this.altViewEnabled) {
|
||||
var extraTitleSpace = this.isBookEntity ? 80 : 40
|
||||
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||
}
|
||||
return this.entityHeight + 40
|
||||
},
|
||||
totalEntityCardWidth() {
|
||||
if (this.showBookshelfListView) return this.entityWidth
|
||||
// Includes margin
|
||||
return this.entityWidth + 24
|
||||
},
|
||||
altViewEnabled() {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.entityWidth / baseSize
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -302,6 +313,9 @@ export default {
|
||||
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
||||
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
||||
|
||||
const entitiesPerPage = this.shelvesPerPage * this.entitiesPerShelf
|
||||
this.booksPerFetch = Math.ceil(entitiesPerPage / 20) * 20 // Round up to the nearest 20
|
||||
|
||||
if (this.totalEntities) {
|
||||
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
|
||||
}
|
||||
@@ -382,7 +396,7 @@ export default {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
libraryChanged(libid) {
|
||||
libraryChanged() {
|
||||
if (this.hasFilter) {
|
||||
this.clearFilter()
|
||||
} else {
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<template>
|
||||
<div class="w-full relative">
|
||||
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
|
||||
<div v-if="altViewEnabled" class="px-5 pb-3 pt-4">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end px-3 max-w-full overflow-x-auto" :class="altViewEnabled ? '' : 'bookshelfRow'" :style="{ height: shelfHeight + 'px', paddingBottom: entityPaddingBottom + 'px' }">
|
||||
<template v-for="(entity, index) in entities">
|
||||
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
||||
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
||||
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
||||
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" class="mx-2 relative" />
|
||||
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" class="mx-2 relative" />
|
||||
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" is-categorized class="mx-2 relative" />
|
||||
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<div v-if="!altViewEnabled" class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border shinyBlack">
|
||||
<p class="transform text-xs">{{ label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-5 z-40 bookshelfDivider"></div>
|
||||
<div v-if="!altViewEnabled" class="w-full h-5 z-40 bookshelfDivider"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,23 +36,43 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
entityPaddingBottom() {
|
||||
if (!this.altViewEnabled) return 0
|
||||
if (this.type === 'authors') return 10
|
||||
else if (this.type === 'series') return 40
|
||||
return 60 * this.sizeMultiplier
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.altViewEnabled) {
|
||||
var extraTitleSpace = this.type === 'authors' ? 10 : this.type === 'series' ? 50 : 60
|
||||
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||
}
|
||||
return this.entityHeight + 40
|
||||
},
|
||||
bookWidth() {
|
||||
var coverSize = 100
|
||||
if (this.bookCoverAspectRatio === 1) return coverSize * 1.6
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
return coverSize
|
||||
},
|
||||
bookHeight() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.bookWidth
|
||||
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
||||
return this.bookWidth * 1.6
|
||||
},
|
||||
entityHeight() {
|
||||
return this.bookHeight
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.bookWidth / baseSize
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
altViewEnabled() {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<!-- <div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default {
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
showSequence: Boolean,
|
||||
bookshelfView: Number,
|
||||
isAltViewEnabled: Boolean,
|
||||
bookMount: {
|
||||
// Book can be passed as prop or set with setEntity()
|
||||
type: Object,
|
||||
@@ -346,6 +346,11 @@ export default {
|
||||
return this.author.slice(0, 27) + '...'
|
||||
}
|
||||
return this.author
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAltViewEnabled) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +19,8 @@ export default {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
isAltViewEnabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,6 +27,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
isAltViewEnabled: Boolean,
|
||||
isCategorized: Boolean
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full max-w-md mx-auto px-4 sm:px-6 lg:px-8 z-10">
|
||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 w-full">
|
||||
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
|
||||
@@ -16,10 +16,18 @@
|
||||
</template>
|
||||
<div v-else class="w-full">
|
||||
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
</div>
|
||||
<h2 class="text-lg leading-7 mb-2">Server address</h2>
|
||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full sm:w-72 h-10" />
|
||||
<div class="flex justify-end">
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10 mt-4">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
||||
<div class="flex justify-end items-center mt-6">
|
||||
<!-- <div class="relative flex">
|
||||
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
|
||||
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
|
||||
</div> -->
|
||||
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<template v-if="showAuth">
|
||||
@@ -62,6 +70,8 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -69,23 +79,26 @@
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
deviceData: null,
|
||||
loggedIn: false,
|
||||
showAuth: false,
|
||||
processing: false,
|
||||
serverConfig: {
|
||||
address: null,
|
||||
username: null
|
||||
username: null,
|
||||
customHeaders: null
|
||||
},
|
||||
password: null,
|
||||
error: null,
|
||||
showForm: false
|
||||
showForm: false,
|
||||
showAddCustomHeaders: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
deviceData() {
|
||||
return this.$store.state.deviceData || {}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
@@ -98,9 +111,16 @@ export default {
|
||||
lastServerConnectionConfig() {
|
||||
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
||||
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
||||
},
|
||||
numCustomHeaders() {
|
||||
if (!this.serverConfig.customHeaders) return 0
|
||||
return Object.keys(this.serverConfig.customHeaders).length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addCustomHeaders() {
|
||||
this.showAddCustomHeaders = true
|
||||
},
|
||||
showServerList() {
|
||||
this.showForm = false
|
||||
this.showAuth = false
|
||||
@@ -132,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
|
||||
}
|
||||
@@ -147,7 +167,10 @@ export default {
|
||||
if (value) {
|
||||
this.processing = true
|
||||
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
|
||||
this.deviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
||||
const updatedDeviceData = { ...this.deviceData }
|
||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||
|
||||
this.serverConfig = {
|
||||
address: null,
|
||||
userId: null,
|
||||
@@ -165,7 +188,6 @@ export default {
|
||||
}
|
||||
this.showForm = true
|
||||
this.showAuth = true
|
||||
console.log('Edit server config', serverConfig)
|
||||
},
|
||||
newServerConfigClick() {
|
||||
this.serverConfig = {
|
||||
@@ -192,9 +214,13 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
pingServerAddress(address) {
|
||||
pingServerAddress(address, customHeaders) {
|
||||
const options = { timeout: 3000 }
|
||||
if (customHeaders) {
|
||||
options.headers = customHeaders
|
||||
}
|
||||
return this.$axios
|
||||
.$get(`${address}/ping`, { timeout: 3000 })
|
||||
.$get(`${address}/ping`, options)
|
||||
.then((data) => data.success)
|
||||
.catch((error) => {
|
||||
console.error('Server check failed', error)
|
||||
@@ -203,8 +229,12 @@ export default {
|
||||
})
|
||||
},
|
||||
requestServerLogin() {
|
||||
const options = {}
|
||||
if (this.serverConfig.customHeaders) {
|
||||
options.headers = this.serverConfig.customHeaders
|
||||
}
|
||||
return this.$axios
|
||||
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password })
|
||||
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
|
||||
.then((data) => {
|
||||
if (!data.user) {
|
||||
console.error(data.error)
|
||||
@@ -236,7 +266,7 @@ export default {
|
||||
this.processing = true
|
||||
this.error = null
|
||||
|
||||
var success = await this.pingServerAddress(this.serverConfig.address)
|
||||
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
|
||||
this.processing = false
|
||||
if (success) this.showAuth = true
|
||||
},
|
||||
@@ -246,20 +276,29 @@ export default {
|
||||
this.error = 'Invalid username'
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
this.error = null
|
||||
this.processing = true
|
||||
|
||||
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))) {
|
||||
@@ -298,8 +337,6 @@ export default {
|
||||
return authRes
|
||||
},
|
||||
async init() {
|
||||
this.deviceData = await this.$db.getDeviceData()
|
||||
|
||||
if (this.lastServerConnectionConfig) {
|
||||
this.connectToServer(this.lastServerConnectionConfig)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="'90%'" :max-width="'420px'" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Custom Headers</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop>
|
||||
<div class="w-full h-full p-4" v-if="showAddHeader">
|
||||
<div class="mb-4">
|
||||
<ui-icon-btn icon="arrow_back" borderless @click="showAddHeader = false" />
|
||||
</div>
|
||||
<form @submit.prevent="submitForm">
|
||||
<ui-text-input-with-label v-model="newHeaderKey" label="Name" class="mb-2" />
|
||||
<ui-text-input-with-label v-model="newHeaderValue" label="Value" class="mb-4" />
|
||||
|
||||
<ui-btn type="submit" class="w-full">Submit</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<div class="w-full h-full p-4" v-else>
|
||||
<template v-for="[key, value] in Object.entries(headersCopy)">
|
||||
<div :key="key" class="w-full rounded-lg bg-white bg-opacity-5 py-2 pl-4 pr-12 relative mb-2">
|
||||
<p class="text-base font-semibold text-gray-200 leading-5">{{ key }}</p>
|
||||
<p class="text-sm text-gray-400">{{ value }}</p>
|
||||
|
||||
<div class="absolute top-0 bottom-0 right-0 h-full p-4 flex items-center justify-center text-error">
|
||||
<button @click="removeHeader(key)"><span class="material-icons text-lg">delete</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!Object.keys(headersCopy).length" class="py-4 text-center">No Custom Headers</p>
|
||||
|
||||
<div class="w-full flex justify-center pt-4">
|
||||
<ui-btn @click="showAddHeader = true" class="w-full">Add Custom Header</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
customHeaders: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newHeaderKey: '',
|
||||
newHeaderValue: '',
|
||||
headersCopy: {},
|
||||
showAddHeader: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(val) {
|
||||
if (val) this.init()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeHeader(key) {
|
||||
this.$delete(this.headersCopy, key)
|
||||
this.$emit('update:customHeaders', { ...this.headersCopy })
|
||||
},
|
||||
submitForm() {
|
||||
console.log('Submit form', this.newHeaderKey, this.newHeaderValue)
|
||||
this.headersCopy[this.newHeaderKey] = this.newHeaderValue
|
||||
this.newHeaderKey = ''
|
||||
this.newHeaderValue = ''
|
||||
this.showAddHeader = false
|
||||
this.$emit('update:customHeaders', { ...this.headersCopy })
|
||||
},
|
||||
init() {
|
||||
this.newHeaderKey = ''
|
||||
this.newHeaderValue = ''
|
||||
this.headersCopy = this.customHeaders ? { ...this.customHeaders } : {}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<covers-book-cover :library-item="libraryItem" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
width: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(val) {
|
||||
if (val) {
|
||||
this.setWidth()
|
||||
this.setListeners()
|
||||
} else {
|
||||
this.removeListeners()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
screenOrientationChange() {
|
||||
setTimeout(this.setWidth, 50)
|
||||
},
|
||||
setListeners() {
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
},
|
||||
removeListeners() {
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
},
|
||||
setWidth() {
|
||||
if (window.innerHeight > window.innerWidth) {
|
||||
this.width = window.innerWidth
|
||||
} else {
|
||||
this.width = window.innerHeight / this.bookCoverAspectRatio
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setWidth()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filter-modal-wrapper {
|
||||
max-height: calc(100% - 320px);
|
||||
}
|
||||
</style>
|
||||
@@ -51,7 +51,7 @@ export default {
|
||||
async clickedOption(lib) {
|
||||
this.show = false
|
||||
await this.$store.dispatch('libraries/fetch', lib.id)
|
||||
this.$eventBus.$emit('library-changed', lib.id)
|
||||
this.$eventBus.$emit('library-changed')
|
||||
this.$localStore.setLastLibraryId(lib.id)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="max-width: 90%; min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,6 +28,10 @@ export default {
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: 'unset'
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: '90%'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -73,6 +77,8 @@ export default {
|
||||
}
|
||||
},
|
||||
setShow() {
|
||||
this.$store.commit('globals/setIsModalOpen', true)
|
||||
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
@@ -80,18 +86,28 @@ export default {
|
||||
document.documentElement.classList.add('modal-open')
|
||||
},
|
||||
setHide() {
|
||||
this.$store.commit('globals/setIsModalOpen', false)
|
||||
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
},
|
||||
closeModalEvt() {
|
||||
console.log('Close modal event')
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('close-modal', this.closeModalEvt)
|
||||
this.el = this.$refs.wrapper
|
||||
this.content = this.$refs.content
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
this.el.style.opacity = 1
|
||||
this.el.remove()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Playback Speed</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="closeMenu">
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="rate in rates">
|
||||
@@ -91,15 +91,17 @@ export default {
|
||||
var newPlaybackRate = this.selected - 0.1
|
||||
this.selected = Number(newPlaybackRate.toFixed(1))
|
||||
},
|
||||
closeMenu() {
|
||||
if (this.currentPlaybackRate !== this.selected) {
|
||||
this.$emit('change', this.selected)
|
||||
modalInput(val) {
|
||||
if (!val) {
|
||||
if (this.currentPlaybackRate !== this.selected) {
|
||||
this.$emit('change', this.selected)
|
||||
}
|
||||
}
|
||||
this.show = false
|
||||
},
|
||||
clickedOption(rate) {
|
||||
this.selected = Number(rate)
|
||||
this.$nextTick(this.closeMenu)
|
||||
this.show = false
|
||||
this.$emit('change', Number(rate))
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<div v-if="book" class="flex h-20">
|
||||
<div v-if="book" class="flex w-full">
|
||||
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="w-80 h-full px-2 flex items-center">
|
||||
<div>
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate text-sm">{{ bookTitle }}</nuxt-link>
|
||||
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,13 +47,13 @@ export default {
|
||||
return this.mediaMetadata.authorName || ''
|
||||
},
|
||||
bookDuration() {
|
||||
return this.$secondsToTimestamp(this.media.duration)
|
||||
return this.$elapsedPretty(this.media.duration)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
bookWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 80
|
||||
if (this.bookCoverAspectRatio === 1) return 50
|
||||
return 50
|
||||
},
|
||||
isMissing() {
|
||||
@@ -78,4 +79,11 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.book-table-content {
|
||||
width: calc(100% - 50px);
|
||||
max-width: calc(100% - 50px);
|
||||
}
|
||||
</style>
|
||||
@@ -16,9 +16,8 @@
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">
|
||||
{{ description }}
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div v-if="icon" class="flex h-full items-center px-2">
|
||||
<span class="material-icons-outlined text-lg" :class="iconClass" @click="showAlertDialog">{{ icon }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
networkConnectionType() {
|
||||
return this.$store.state.networkConnectionType
|
||||
},
|
||||
isNetworkUnmetered() {
|
||||
return this.$store.state.isNetworkUnmetered
|
||||
},
|
||||
isCellular() {
|
||||
return this.networkConnectionType === 'cellular'
|
||||
},
|
||||
icon() {
|
||||
if (!this.user) return null // hide when not connected to server
|
||||
|
||||
if (!this.networkConnected) {
|
||||
return 'wifi_off'
|
||||
} else if (!this.socketConnected) {
|
||||
return 'cloud_off'
|
||||
} else if (this.isCellular) {
|
||||
return 'signal_cellular_alt'
|
||||
} else {
|
||||
return 'cloud_done'
|
||||
}
|
||||
},
|
||||
iconClass() {
|
||||
if (!this.networkConnected) return 'text-error'
|
||||
else if (!this.socketConnected) return 'text-warning'
|
||||
else if (!this.isNetworkUnmetered) return 'text-yellow-400'
|
||||
else if (this.isCellular) return 'text-gray-200'
|
||||
else return 'text-success'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showAlertDialog() {
|
||||
var msg = ''
|
||||
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
|
||||
if (!this.networkConnected) {
|
||||
msg = 'No internet'
|
||||
} else if (!this.socketConnected) {
|
||||
msg = 'Socket not connected'
|
||||
} else if (this.isCellular) {
|
||||
msg = `Socket connected over ${meteredString} cellular`
|
||||
} else {
|
||||
msg = `Socket connected over ${meteredString} wifi`
|
||||
}
|
||||
Dialog.alert({
|
||||
title: 'Connection Status',
|
||||
message: msg
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -27,6 +27,7 @@
|
||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
|
||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
|
||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
@@ -59,6 +60,7 @@
|
||||
4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; };
|
||||
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
||||
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
@@ -134,6 +136,7 @@
|
||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
|
||||
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||
);
|
||||
path = models;
|
||||
sourceTree = "<group>";
|
||||
@@ -325,6 +328,7 @@
|
||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
|
||||
@@ -475,12 +479,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
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.47;
|
||||
MARKETING_VERSION = 0.9.55;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -499,12 +503,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
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.47;
|
||||
MARKETING_VERSION = 0.9.55;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
|
||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
||||
let libraryItemId = call.getString("libraryItemId")
|
||||
let episodeId = call.getString("episodeId")
|
||||
@@ -43,10 +44,10 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
initialPlayWhenReady = playWhenReady
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
PlayerHandler.stopPlayback()
|
||||
|
||||
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
||||
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
|
||||
do {
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
call.resolve(try session.asDictionary())
|
||||
@@ -56,6 +57,8 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
call.resolve([:])
|
||||
}
|
||||
|
||||
|
||||
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
self.sendMetadata()
|
||||
}
|
||||
}
|
||||
@@ -173,7 +176,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
let playbackSession = PlayerHandler.getPlaybackSession()
|
||||
let libraryItemId = playbackSession?.libraryItemId ?? ""
|
||||
let episodeId = playbackSession?.episodeId ?? nil
|
||||
NSLog("TEST: Forcing Transcode")
|
||||
NSLog("Forcing Transcode")
|
||||
|
||||
// If direct playing then fallback to transcode
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
|
||||
|
||||
@@ -17,7 +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);
|
||||
)
|
||||
|
||||
|
||||
@@ -65,11 +65,12 @@ public class AbsDatabase: CAPPlugin {
|
||||
@objc func getDeviceData(_ call: CAPPluginCall) {
|
||||
let configs = Database.shared.getServerConnectionConfigs()
|
||||
let index = Database.shared.getLastActiveConfigIndex()
|
||||
let settings = Database.shared.getDeviceSettings()
|
||||
|
||||
call.resolve([
|
||||
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
|
||||
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
|
||||
// "currentLocalPlaybackSession": nil,
|
||||
"deviceSettings": deviceSettingsToJSON(settings: settings)
|
||||
])
|
||||
}
|
||||
|
||||
@@ -79,10 +80,29 @@ 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
|
||||
|
||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
||||
|
||||
// call.resolve([ "value": [] ])
|
||||
getDeviceData(call)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -9,12 +9,12 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorApp', :path => '..\..\node_modules\@capacitor\app'
|
||||
pod 'CapacitorDialog', :path => '..\..\node_modules\@capacitor\dialog'
|
||||
pod 'CapacitorHaptics', :path => '..\..\node_modules\@capacitor\haptics'
|
||||
pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network'
|
||||
pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar'
|
||||
pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
||||
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
||||
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
|
||||
end
|
||||
|
||||
target 'App' do
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// DeviceSettings.swift
|
||||
// App
|
||||
//
|
||||
// Created by advplyr on 7/2/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class DeviceSettings: Object {
|
||||
@Persisted var disableAutoRewind: Bool
|
||||
@Persisted var enableAltView: Bool
|
||||
@Persisted var jumpBackwardsTime: Int
|
||||
@Persisted var jumpForwardTime: Int
|
||||
}
|
||||
|
||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = false
|
||||
settings.enableAltView = false
|
||||
settings.jumpForwardTime = 10
|
||||
settings.jumpBackwardsTime = 10
|
||||
return settings
|
||||
}
|
||||
|
||||
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||
return Database.realmQueue.sync {
|
||||
return [
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"jumpForwardTime": settings.jumpForwardTime
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,10 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
|
||||
NSLog("TEST: Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
|
||||
NSLog("Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
|
||||
|
||||
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
|
||||
NSLog("TEST: Setting player items \(playerItems.count)")
|
||||
NSLog("Setting player items \(playerItems.count)")
|
||||
|
||||
for item in Array(playerItems) {
|
||||
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
|
||||
@@ -123,7 +123,7 @@ class AudioPlayer: NSObject {
|
||||
self.audioPlayer.currentItem.map { item in
|
||||
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
|
||||
if (self.currentTrackIndex != prevTrackIndex) {
|
||||
NSLog("TEST: New Current track index \(self.currentTrackIndex)")
|
||||
NSLog("New Current track index \(self.currentTrackIndex)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class AudioPlayer: NSObject {
|
||||
self.queueItemStatusObserver?.invalidate()
|
||||
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
|
||||
if (playerItem.status == .readyToPlay) {
|
||||
NSLog("TEST: queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
||||
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
||||
self.updateNowPlaying()
|
||||
|
||||
let firstReady = self.status < 0
|
||||
@@ -146,7 +146,7 @@ class AudioPlayer: NSObject {
|
||||
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
|
||||
}
|
||||
} else if (playerItem.status == .failed) {
|
||||
NSLog("TEST: queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
||||
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
||||
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
}
|
||||
@@ -203,16 +203,16 @@ class AudioPlayer: NSObject {
|
||||
|
||||
pause()
|
||||
|
||||
NSLog("TEST: Seek to \(to) from \(from)")
|
||||
NSLog("Seek to \(to) from \(from)")
|
||||
|
||||
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
|
||||
let ctso = currentTrack.startOffset ?? 0.0
|
||||
let trackEnd = ctso + currentTrack.duration
|
||||
NSLog("TEST: Seek current track END = \(trackEnd)")
|
||||
NSLog("Seek current track END = \(trackEnd)")
|
||||
|
||||
|
||||
let indexOfSeek = getItemIndexForTime(time: to)
|
||||
NSLog("TEST: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
||||
NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
||||
|
||||
// Reconstruct queue if seeking to a different track
|
||||
if (self.currentTrackIndex != indexOfSeek) {
|
||||
@@ -231,7 +231,7 @@ class AudioPlayer: NSObject {
|
||||
|
||||
setupQueueItemStatusObserver()
|
||||
} else {
|
||||
NSLog("TEST: Seeking in current item \(to)")
|
||||
NSLog("Seeking in current item \(to)")
|
||||
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
|
||||
let seekTime = to - currentTrackStartOffset
|
||||
|
||||
@@ -250,7 +250,7 @@ class AudioPlayer: NSObject {
|
||||
|
||||
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
|
||||
if self.audioPlayer.rate != rate {
|
||||
NSLog("TEST: setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
||||
NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
||||
self.audioPlayer.rate = rate
|
||||
}
|
||||
if rate > 0.0 && !(observed && rate == 1) {
|
||||
@@ -373,7 +373,7 @@ class AudioPlayer: NSObject {
|
||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if context == &playerContext {
|
||||
if keyPath == #keyPath(AVPlayer.rate) {
|
||||
NSLog("TEST: playerContext observer player rate")
|
||||
NSLog("playerContext observer player rate")
|
||||
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||
} else if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,4 +128,24 @@ class Database {
|
||||
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
||||
}
|
||||
}
|
||||
public func setDeviceSettings(deviceSettings: DeviceSettings) {
|
||||
Database.realmQueue.sync {
|
||||
let existing = instance.objects(DeviceSettings.self)
|
||||
|
||||
do {
|
||||
try instance.write {
|
||||
instance.delete(existing)
|
||||
instance.add(deviceSettings)
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("failed to save device settings")
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func getDeviceSettings() -> DeviceSettings {
|
||||
return Database.realmQueue.sync {
|
||||
return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-19
@@ -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
|
||||
@@ -170,13 +162,14 @@ export default {
|
||||
this.inittingLibraries = true
|
||||
await this.$store.dispatch('libraries/load')
|
||||
console.log(`[default] initLibraries loaded ${this.currentLibraryId}`)
|
||||
await this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||
this.$eventBus.$emit('library-changed')
|
||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||
this.inittingLibraries = false
|
||||
},
|
||||
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,15 +253,19 @@ 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)
|
||||
|
||||
if (this.$store.state.isFirstLoad) {
|
||||
this.$store.commit('setIsFirstLoad', false)
|
||||
|
||||
const deviceData = await this.$db.getDeviceData()
|
||||
this.$store.commit('setDeviceData', deviceData)
|
||||
|
||||
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()
|
||||
@@ -272,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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Generated
+3
-2
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.48-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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.48-beta",
|
||||
"version": "0.9.55-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
|
||||
+10
-4
@@ -53,10 +53,16 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async logout() {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
this.$server.logout()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
this.$socket.logout()
|
||||
await this.$db.logout()
|
||||
this.$localStore.removeLastLibraryId()
|
||||
this.$store.commit('user/logout')
|
||||
this.$router.push('/connect')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
<home-bookshelf-toolbar v-show="!isHome" />
|
||||
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''">
|
||||
<nuxt-child />
|
||||
|
||||
<!-- <div v-if="isLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-full min-h-full relative">
|
||||
<div v-if="!loading" class="w-full">
|
||||
<div v-if="!loading" class="w-full" :class="{ 'py-6': altViewEnabled }">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<bookshelf-shelf :key="shelf.id" :label="shelf.label" :entities="shelf.entities" :type="shelf.type" :style="{ zIndex: shelves.length - index }" />
|
||||
</template>
|
||||
@@ -53,6 +53,9 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
altViewEnabled() {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -135,7 +138,7 @@ export default {
|
||||
}
|
||||
this.loading = false
|
||||
},
|
||||
async libraryChanged(libid) {
|
||||
async libraryChanged() {
|
||||
if (this.currentLibraryId) {
|
||||
await this.fetchCategories()
|
||||
}
|
||||
@@ -183,12 +186,10 @@ export default {
|
||||
})
|
||||
},
|
||||
initListeners() {
|
||||
// this.$server.on('initialized', this.socketInit)
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
// this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
|
||||
},
|
||||
removeListeners() {
|
||||
// this.$server.off('initialized', this.socketInit)
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
// this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<covers-collection-cover :book-items="bookItems" :width="240" :height="120 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow py-6">
|
||||
<div class="flex items-center px-2">
|
||||
<h1 class="text-xl font-sans">
|
||||
{{ collectionName }}
|
||||
</h1>
|
||||
@@ -18,7 +18,7 @@
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="my-8 max-w-2xl">
|
||||
<div class="my-8 max-w-2xl px-2">
|
||||
<p class="text-base text-gray-100">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
+6
-2
@@ -12,7 +12,7 @@
|
||||
|
||||
<!-- <p class="absolute bottom-16 left-0 right-0 px-2 text-center text-error"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p> -->
|
||||
|
||||
<connection-server-connect-form />
|
||||
<connection-server-connect-form v-if="deviceData" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center pt-4 fixed bottom-4 left-0 right-0">
|
||||
@@ -32,7 +32,9 @@
|
||||
export default {
|
||||
layout: 'blank',
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
deviceData: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
networkConnected() {
|
||||
@@ -41,6 +43,8 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.deviceData = await this.$db.getDeviceData()
|
||||
this.$store.commit('setDeviceData', this.deviceData)
|
||||
await this.$store.dispatch('setupNetworkListener')
|
||||
}
|
||||
},
|
||||
|
||||
+8
-5
@@ -2,7 +2,7 @@
|
||||
<div class="w-full h-full px-3 py-4 overflow-y-auto">
|
||||
<div class="flex">
|
||||
<div class="w-16">
|
||||
<div class="relative">
|
||||
<div class="relative" @click="showFullscreenCover = true">
|
||||
<covers-book-cover :library-item="libraryItem" :width="64" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 64 * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
@@ -21,7 +21,7 @@
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span></template
|
||||
>
|
||||
</p>
|
||||
<p v-if="podcastAuthor" class="text-sm text-gray-400 py-0.5">By {{ author }}</p>
|
||||
<p v-if="podcastAuthor" class="text-sm text-gray-400 py-0.5">By {{ podcastAuthor }}</p>
|
||||
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-400 py-0.5">
|
||||
By
|
||||
<template v-for="(author, index) in bookAuthors"
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="isLocal" class="flex mt-4">
|
||||
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
|
||||
<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>
|
||||
@@ -112,6 +112,8 @@
|
||||
<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>
|
||||
|
||||
@@ -134,7 +136,7 @@ export default {
|
||||
})
|
||||
// Check if
|
||||
if (libraryItem) {
|
||||
var localLibraryItem = await app.$db.getLocalLibraryItemByLLId(libraryItemId)
|
||||
var localLibraryItem = await app.$db.getLocalLibraryItemByLId(libraryItemId)
|
||||
if (localLibraryItem) {
|
||||
console.log('Library item has local library item also', localLibraryItem.id)
|
||||
libraryItem.localLibraryItem = localLibraryItem
|
||||
@@ -156,7 +158,8 @@ export default {
|
||||
isProcessingReadUpdate: false,
|
||||
showSelectLocalFolder: false,
|
||||
showMoreMenu: false,
|
||||
showDetailsModal: false
|
||||
showDetailsModal: false,
|
||||
showFullscreenCover: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div v-else class="w-full media-item-container overflow-y-auto">
|
||||
<template v-for="mediaItem in localLibraryItems">
|
||||
<nuxt-link :to="`/localMedia/item/${mediaItem.id}`" :key="mediaItem.id" class="flex my-1">
|
||||
<div class="w-12 h-12 bg-primary">
|
||||
<div class="w-12 h-12 min-w-12 min-h-12 bg-primary">
|
||||
<img v-if="mediaItem.coverPathSrc" :src="mediaItem.coverPathSrc" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
<template>
|
||||
<div class="w-full h-full py-6">
|
||||
<div v-if="lastLocalMediaSyncResults" class="px-2 mb-4">
|
||||
<div class="w-full pl-2 pr-2 py-2 bg-black bg-opacity-25 rounded-lg relative">
|
||||
<div class="flex items-center mb-1">
|
||||
<span class="material-icons text-success text-xl">sync</span>
|
||||
<p class="text-sm text-gray-300 pl-2">Local media progress synced with server</p>
|
||||
</div>
|
||||
<div class="flex justify-between mb-1.5">
|
||||
<p class="text-xs text-gray-400 font-semibold">{{ syncedServerConfigName }}</p>
|
||||
<p class="text-xs text-gray-400 italic">{{ $dateDistanceFromNow(syncedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!numLocalProgressUpdates && !numServerProgressUpdates">
|
||||
<p class="text-sm text-gray-300">Local media progress was up-to-date with server ({{ numLocalMediaSynced }} item{{ numLocalMediaSynced == 1 ? '' : 's' }})</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p v-if="numServerProgressUpdates" class="text-sm text-gray-300">- {{ numServerProgressUpdates }} local media item{{ numServerProgressUpdates === 1 ? '' : 's' }} progress was updated on the server (local more recent).</p>
|
||||
<p v-else class="text-sm text-gray-300">- No local media progress had to be synced on the server.</p>
|
||||
<p v-if="numLocalProgressUpdates" class="text-sm text-gray-300">- {{ numLocalProgressUpdates }} local media item{{ numLocalProgressUpdates === 1 ? '' : 's' }} progress was updated to match the server (server more recent).</p>
|
||||
<p v-else class="text-sm text-gray-300">- No server progress had to be synced with local media progress.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-base font-semibold px-3 mb-2">Local Folders</h1>
|
||||
|
||||
<div v-if="!isIos" class="w-full max-w-full px-3 py-2">
|
||||
@@ -49,8 +72,28 @@ export default {
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
isSocketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
lastLocalMediaSyncResults() {
|
||||
return this.$store.state.lastLocalMediaSyncResults
|
||||
},
|
||||
numLocalMediaSynced() {
|
||||
if (!this.lastLocalMediaSyncResults) return 0
|
||||
return this.lastLocalMediaSyncResults.numLocalMediaProgressForServer || 0
|
||||
},
|
||||
syncedAt() {
|
||||
if (!this.lastLocalMediaSyncResults) return 0
|
||||
return this.lastLocalMediaSyncResults.syncedAt || 0
|
||||
},
|
||||
syncedServerConfigName() {
|
||||
if (!this.lastLocalMediaSyncResults) return ''
|
||||
return this.lastLocalMediaSyncResults.serverConfigName
|
||||
},
|
||||
numLocalProgressUpdates() {
|
||||
if (!this.lastLocalMediaSyncResults) return 0
|
||||
return this.lastLocalMediaSyncResults.numLocalProgressUpdates || 0
|
||||
},
|
||||
numServerProgressUpdates() {
|
||||
if (!this.lastLocalMediaSyncResults) return 0
|
||||
return this.lastLocalMediaSyncResults.numServerProgressUpdates || 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
+52
-46
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full h-full p-8">
|
||||
<div class="flex items-center py-3" @click="toggleDisableAutoRewind">
|
||||
<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" />
|
||||
</div>
|
||||
<p class="pl-4">Alternative Bookshelf View</p>
|
||||
</div>
|
||||
<div v-if="$platform !== 'ios'" class="flex items-center py-3" @click="toggleDisableAutoRewind">
|
||||
<div class="w-10 flex justify-center">
|
||||
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
|
||||
</div>
|
||||
@@ -12,9 +18,9 @@
|
||||
</div>
|
||||
<p class="pl-4">Jump backwards time</p>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click="toggleJumpForwards">
|
||||
<div class="flex items-center py-3" @click="toggleJumpForward">
|
||||
<div class="w-10 flex justify-center">
|
||||
<span class="material-icons text-4xl">{{ currentJumpForwardsTimeIcon }}</span>
|
||||
<span class="material-icons text-4xl">{{ currentJumpForwardTimeIcon }}</span>
|
||||
</div>
|
||||
<p class="pl-4">Jump forwards time</p>
|
||||
</div>
|
||||
@@ -25,53 +31,35 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
deviceData: null,
|
||||
settings: {
|
||||
disableAutoRewind: false,
|
||||
jumpForwardsTime: 10000,
|
||||
jumpBackwardsTime: 10000
|
||||
},
|
||||
jumpForwardsItems: [
|
||||
{
|
||||
icon: 'forward_5',
|
||||
value: 5000
|
||||
},
|
||||
{
|
||||
icon: 'forward_10',
|
||||
value: 10000
|
||||
},
|
||||
{
|
||||
icon: 'forward_30',
|
||||
value: 30000
|
||||
}
|
||||
],
|
||||
jumpBackwardsItems: [
|
||||
{
|
||||
icon: 'replay_5',
|
||||
value: 5000
|
||||
},
|
||||
{
|
||||
icon: 'replay_10',
|
||||
value: 10000
|
||||
},
|
||||
{
|
||||
icon: 'replay_30',
|
||||
value: 30000
|
||||
}
|
||||
]
|
||||
enableAltView: false,
|
||||
jumpForwardTime: 10,
|
||||
jumpBackwardsTime: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentJumpForwardsTimeIcon() {
|
||||
return this.jumpForwardsItems[this.currentJumpForwardsTimeIndex].icon
|
||||
jumpForwardItems() {
|
||||
return this.$store.state.globals.jumpForwardItems || []
|
||||
},
|
||||
currentJumpForwardsTimeIndex() {
|
||||
return this.jumpForwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpForwardsTime)
|
||||
jumpBackwardsItems() {
|
||||
return this.$store.state.globals.jumpBackwardsItems || []
|
||||
},
|
||||
currentJumpForwardTimeIcon() {
|
||||
return this.jumpForwardItems[this.currentJumpForwardTimeIndex].icon
|
||||
},
|
||||
currentJumpForwardTimeIndex() {
|
||||
var index = this.jumpForwardItems.findIndex((jfi) => jfi.value === this.settings.jumpForwardTime)
|
||||
return index >= 0 ? index : 1
|
||||
},
|
||||
currentJumpBackwardsTimeIcon() {
|
||||
return this.jumpBackwardsItems[this.currentJumpBackwardsTimeIndex].icon
|
||||
},
|
||||
currentJumpBackwardsTimeIndex() {
|
||||
return this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
|
||||
var index = this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
|
||||
return index >= 0 ? index : 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -79,24 +67,42 @@ export default {
|
||||
this.settings.disableAutoRewind = !this.settings.disableAutoRewind
|
||||
this.saveSettings()
|
||||
},
|
||||
toggleJumpForwards() {
|
||||
var next = (this.currentJumpForwardsTimeIndex + 1) % 3
|
||||
this.settings.jumpForwardsTime = this.jumpForwardsItems[next].value
|
||||
toggleEnableAltView() {
|
||||
this.settings.enableAltView = !this.settings.enableAltView
|
||||
this.saveSettings()
|
||||
},
|
||||
toggleJumpForward() {
|
||||
var next = (this.currentJumpForwardTimeIndex + 1) % 3
|
||||
this.settings.jumpForwardTime = this.jumpForwardItems[next].value
|
||||
this.saveSettings()
|
||||
},
|
||||
toggleJumpBackwards() {
|
||||
var next = (this.currentJumpBackwardsTimeIndex + 4) % 3
|
||||
console.log('next', next)
|
||||
if (next > 2) return
|
||||
this.settings.jumpBackwardsTime = this.jumpBackwardsItems[next].value
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
// TODO: Save settings
|
||||
async saveSettings() {
|
||||
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
|
||||
console.log('Saved device data', updatedDeviceData)
|
||||
if (updatedDeviceData) {
|
||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
async init() {
|
||||
this.deviceData = await this.$db.getDeviceData()
|
||||
this.$store.commit('setDeviceData', this.deviceData)
|
||||
|
||||
const deviceSettings = this.deviceData.deviceSettings || {}
|
||||
this.settings.disableAutoRewind = !!deviceSettings.disableAutoRewind
|
||||
this.settings.enableAltView = !!deviceSettings.enableAltView
|
||||
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
|
||||
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// TODO: Load settings
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,14 @@ export default function ({ $axios, store }) {
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
|
||||
var customHeaders = store.getters['user/getCustomHeaders']
|
||||
if (customHeaders) {
|
||||
for (const key in customHeaders) {
|
||||
config.headers.common[key] = customHeaders[key]
|
||||
}
|
||||
}
|
||||
|
||||
var bearerToken = store.getters['user/getToken']
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
|
||||
@@ -131,6 +131,11 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// PluginMethod
|
||||
async getIsCastAvailable() {
|
||||
return false
|
||||
}
|
||||
|
||||
initializePlayer() {
|
||||
if (document.getElementById('audio-player')) {
|
||||
document.getElementById('audio-player').remove()
|
||||
|
||||
@@ -13,7 +13,8 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
const deviceData = {
|
||||
serverConnectionConfigs: [],
|
||||
lastServerConnectionConfigId: null,
|
||||
currentLocalPlaybackSession: null
|
||||
currentLocalPlaybackSession: null,
|
||||
deviceSettings: {}
|
||||
}
|
||||
return deviceData
|
||||
}
|
||||
@@ -28,6 +29,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
ssc.token = serverConnectionConfig.token
|
||||
ssc.userId = serverConnectionConfig.userId
|
||||
ssc.username = serverConnectionConfig.username
|
||||
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||
} else {
|
||||
ssc = {
|
||||
@@ -37,7 +39,8 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
userId: serverConnectionConfig.userId,
|
||||
username: serverConnectionConfig.username,
|
||||
address: serverConnectionConfig.address,
|
||||
token: serverConnectionConfig.token
|
||||
token: serverConnectionConfig.token,
|
||||
customHeaders: serverConnectionConfig.customHeaders || {}
|
||||
}
|
||||
deviceData.serverConnectionConfigs.push(ssc)
|
||||
deviceData.lastServerConnectionConfigId = ssc.id
|
||||
@@ -49,7 +52,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
async removeServerConnectionConfig(serverConnectionConfigCallObject) {
|
||||
var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId
|
||||
var deviceData = await this.getDeviceData()
|
||||
deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter(ssc => ssc.id == serverConnectionConfigId)
|
||||
deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter(ssc => ssc.id != serverConnectionConfigId)
|
||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||
}
|
||||
|
||||
@@ -161,7 +164,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
async getLocalLibraryItem({ id }) {
|
||||
return this.getLocalLibraryItems().then((data) => data.value[0])
|
||||
}
|
||||
async getLocalLibraryItemByLLId({ libraryItemId }) {
|
||||
async getLocalLibraryItemByLId({ libraryItemId }) {
|
||||
return this.getLocalLibraryItems().then((data) => data.value.find(lli => lli.libraryItemId == libraryItemId))
|
||||
}
|
||||
async getAllLocalMediaProgress() {
|
||||
@@ -207,6 +210,13 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
// { localLibraryItemId, localEpisodeId, isFinished }
|
||||
return null
|
||||
}
|
||||
|
||||
async updateDeviceSettings(payload) {
|
||||
var deviceData = await this.getDeviceData()
|
||||
deviceData.deviceSettings = payload
|
||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||
return deviceData
|
||||
}
|
||||
}
|
||||
|
||||
const AbsDatabase = registerPlugin('AbsDatabase', {
|
||||
|
||||
@@ -5,6 +5,12 @@ const DownloadStatus = {
|
||||
FAILED: 3
|
||||
}
|
||||
|
||||
const SyncStatus = {
|
||||
UNSET: 0,
|
||||
SUCCESS: 1,
|
||||
FAILED: 2
|
||||
}
|
||||
|
||||
const CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
@@ -31,6 +37,7 @@ const PlayerState = {
|
||||
|
||||
const Constants = {
|
||||
DownloadStatus,
|
||||
SyncStatus,
|
||||
CoverDestination,
|
||||
BookCoverAspectRatio,
|
||||
PlayMethod,
|
||||
|
||||
+6
-2
@@ -54,8 +54,8 @@ class DbService {
|
||||
return AbsDatabase.getLocalLibraryItem({ id })
|
||||
}
|
||||
|
||||
getLocalLibraryItemByLLId(libraryItemId) {
|
||||
return AbsDatabase.getLocalLibraryItemByLLId({ libraryItemId })
|
||||
getLocalLibraryItemByLId(libraryItemId) {
|
||||
return AbsDatabase.getLocalLibraryItemByLId({ libraryItemId })
|
||||
}
|
||||
|
||||
getAllLocalMediaProgress() {
|
||||
@@ -82,6 +82,10 @@ class DbService {
|
||||
updateLocalMediaProgressFinished(payload) {
|
||||
return AbsDatabase.updateLocalMediaProgressFinished(payload)
|
||||
}
|
||||
|
||||
updateDeviceSettings(payload) {
|
||||
return AbsDatabase.updateDeviceSettings(payload)
|
||||
}
|
||||
}
|
||||
|
||||
export default ({ app, store }, inject) => {
|
||||
|
||||
+46
-19
@@ -14,21 +14,6 @@ if (Capacitor.getPlatform() != 'web') {
|
||||
setStatusBarStyleDark()
|
||||
}
|
||||
|
||||
App.addListener('backButton', async ({ canGoBack }) => {
|
||||
if (!canGoBack) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Did you want to exit the app?`,
|
||||
})
|
||||
if (value) {
|
||||
App.exitApp()
|
||||
}
|
||||
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
})
|
||||
|
||||
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
Vue.prototype.$dateDistanceFromNow = (unixms) => {
|
||||
@@ -52,17 +37,20 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPretty = (seconds) => {
|
||||
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
|
||||
}
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 70) {
|
||||
return `${minutes} min`
|
||||
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
|
||||
}
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
if (!minutes) {
|
||||
return `${hours} hr`
|
||||
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
|
||||
}
|
||||
return `${hours} hr ${minutes} min`
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
@@ -132,6 +120,45 @@ Vue.prototype.$encode = encode
|
||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
Vue.prototype.$decode = decode
|
||||
|
||||
export default ({ store, app }) => {
|
||||
// iOS Only
|
||||
// backButton event does not work with iOS swipe navigation so use this workaround
|
||||
if (app.router && Capacitor.getPlatform() === 'ios') {
|
||||
app.router.beforeEach((to, from, next) => {
|
||||
if (store.state.globals.isModalOpen) {
|
||||
Vue.prototype.$eventBus.$emit('close-modal')
|
||||
}
|
||||
if (store.state.playerIsFullscreen) {
|
||||
Vue.prototype.$eventBus.$emit('minimize-player')
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
// Android only
|
||||
App.addListener('backButton', async ({ canGoBack }) => {
|
||||
if (store.state.globals.isModalOpen) {
|
||||
Vue.prototype.$eventBus.$emit('close-modal')
|
||||
return
|
||||
}
|
||||
if (store.state.playerIsFullscreen) {
|
||||
Vue.prototype.$eventBus.$emit('minimize-player')
|
||||
return
|
||||
}
|
||||
if (!canGoBack) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Did you want to exit the app?`,
|
||||
})
|
||||
if (value) {
|
||||
App.exitApp()
|
||||
}
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
encode,
|
||||
decode
|
||||
|
||||
+10
-1
@@ -83,7 +83,16 @@ class LocalStorage {
|
||||
await Storage.set({ key: 'lastLibraryId', value: libraryId })
|
||||
console.log('[LocalStorage] Set Last Library Id', libraryId)
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to set current library', error)
|
||||
console.error('[LocalStorage] Failed to set last library id', error)
|
||||
}
|
||||
}
|
||||
|
||||
async removeLastLibraryId() {
|
||||
try {
|
||||
await Storage.remove({ key: 'lastLibraryId' })
|
||||
console.log('[LocalStorage] Remove Last Library Id')
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to remove last library id', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -39,6 +39,7 @@ class ServerSocket extends EventEmitter {
|
||||
|
||||
logout() {
|
||||
if (this.socket) this.socket.disconnect()
|
||||
this.removeListeners()
|
||||
}
|
||||
|
||||
setSocketListeners() {
|
||||
@@ -54,6 +55,14 @@ class ServerSocket extends EventEmitter {
|
||||
// })
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
if (!this.socket) return
|
||||
this.socket.removeAllListeners()
|
||||
if (this.socket.io && this.socket.io.removeAllListeners) {
|
||||
this.socket.io.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
onConnect() {
|
||||
console.log('[SOCKET] Socket Connected ' + this.socket.id)
|
||||
this.connected = true
|
||||
@@ -67,18 +76,10 @@ class ServerSocket extends EventEmitter {
|
||||
this.connected = false
|
||||
this.$store.commit('setSocketConnected', false)
|
||||
this.emit('connection-update', false)
|
||||
|
||||
this.socket.removeAllListeners()
|
||||
if (this.socket.io && this.socket.io.removeAllListeners) {
|
||||
this.socket.io.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
onInit(data) {
|
||||
console.log('[SOCKET] Initial socket data received', data)
|
||||
if (data.serverSettings) {
|
||||
this.$store.commit('setServerSettings', data.serverSettings)
|
||||
}
|
||||
this.emit('initialized', true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Audiobookshelf Mobile App
|
||||
|
||||
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
Audiobookshelf is a self-hosted audiobook and podcast server.
|
||||
|
||||
### Android (beta)
|
||||
Get the Android app on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
+45
-2
@@ -1,9 +1,38 @@
|
||||
export const state = () => ({
|
||||
isModalOpen: false,
|
||||
itemDownloads: [],
|
||||
bookshelfListView: false,
|
||||
series: null,
|
||||
localMediaProgress: [],
|
||||
lastSearch: null
|
||||
lastSearch: null,
|
||||
jumpForwardItems: [
|
||||
{
|
||||
icon: 'forward_5',
|
||||
value: 5
|
||||
},
|
||||
{
|
||||
icon: 'forward_10',
|
||||
value: 10
|
||||
},
|
||||
{
|
||||
icon: 'forward_30',
|
||||
value: 30
|
||||
}
|
||||
],
|
||||
jumpBackwardsItems: [
|
||||
{
|
||||
icon: 'replay_5',
|
||||
value: 5
|
||||
},
|
||||
{
|
||||
icon: 'replay_10',
|
||||
value: 10
|
||||
},
|
||||
{
|
||||
icon: 'replay_30',
|
||||
value: 30
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -22,13 +51,16 @@ export const getters = {
|
||||
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
|
||||
|
||||
var userToken = rootGetters['user/getToken']
|
||||
var serverAddress = rootGetters['user/getServerAddress']
|
||||
if (!userToken || !serverAddress) return placeholder
|
||||
|
||||
var lastUpdate = libraryItem.updatedAt || Date.now()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
// return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
}
|
||||
|
||||
var url = new URL(`/api/items/${libraryItem.id}/cover`, rootGetters['user/getServerAddress'])
|
||||
var url = new URL(`/api/items/${libraryItem.id}/cover`, serverAddress)
|
||||
return `${url}?token=${userToken}&ts=${lastUpdate}`
|
||||
},
|
||||
getLocalMediaProgressById: (state) => (localLibraryItemId, episodeId = null) => {
|
||||
@@ -42,6 +74,14 @@ export const getters = {
|
||||
if (episodeId != null && lmp.episodeId != episodeId) return false
|
||||
return lmp.libraryItemId == libraryItemId
|
||||
})
|
||||
},
|
||||
getJumpForwardIcon: state => (jumpForwardTime) => {
|
||||
const item = state.jumpForwardItems.find(i => i.value == jumpForwardTime)
|
||||
return item ? item.icon : 'forward_10'
|
||||
},
|
||||
getJumpBackwardsIcon: state => (jumpBackwardsTime) => {
|
||||
const item = state.jumpBackwardsItems.find(i => i.value == jumpBackwardsTime)
|
||||
return item ? item.icon : 'replay_10'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +94,9 @@ export const actions = {
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setIsModalOpen(state, val) {
|
||||
state.isModalOpen = val
|
||||
},
|
||||
addUpdateItemDownload(state, downloadItem) {
|
||||
var index = state.itemDownloads.findIndex(i => i.id == downloadItem.id)
|
||||
if (index >= 0) {
|
||||
|
||||
+36
-1
@@ -1,15 +1,19 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
|
||||
export const state = () => ({
|
||||
deviceData: null,
|
||||
playerLibraryItemId: null,
|
||||
playerEpisodeId: null,
|
||||
playerIsLocal: false,
|
||||
playerIsPlaying: false,
|
||||
playerIsFullscreen: false,
|
||||
isCasting: false,
|
||||
isCastAvailable: false,
|
||||
socketConnected: false,
|
||||
networkConnected: false,
|
||||
networkConnectionType: null,
|
||||
isNetworkUnmetered: true,
|
||||
isFirstLoad: true,
|
||||
hasStoragePermission: false,
|
||||
selectedLibraryItem: null,
|
||||
@@ -17,7 +21,8 @@ export const state = () => ({
|
||||
showSideDrawer: false,
|
||||
isNetworkListenerInit: false,
|
||||
serverSettings: null,
|
||||
lastBookshelfScrollData: {}
|
||||
lastBookshelfScrollData: {},
|
||||
lastLocalMediaSyncResults: null
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -35,6 +40,18 @@ export const getters = {
|
||||
if (!state.serverSettings) return 1
|
||||
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
||||
},
|
||||
getJumpForwardTime: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return 10
|
||||
return state.deviceData.deviceSettings.jumpForwardTime || 10
|
||||
},
|
||||
getJumpBackwardsTime: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return 10
|
||||
return state.deviceData.deviceSettings.jumpBackwardsTime || 10
|
||||
},
|
||||
getAltViewEnabled: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return false
|
||||
return state.deviceData.deviceSettings.enableAltView
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
@@ -51,10 +68,19 @@ export const actions = {
|
||||
console.log('Network status changed', status.connected, status.connectionType)
|
||||
commit('setNetworkStatus', status)
|
||||
})
|
||||
|
||||
AbsAudioPlayer.addListener('onNetworkMeteredChanged', (payload) => {
|
||||
const isUnmetered = payload.value
|
||||
console.log('On network metered changed', isUnmetered)
|
||||
commit('setIsNetworkUnmetered', isUnmetered)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setDeviceData(state, deviceData) {
|
||||
state.deviceData = deviceData
|
||||
},
|
||||
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
|
||||
state.lastBookshelfScrollData[name] = { scrollTop, path }
|
||||
},
|
||||
@@ -81,6 +107,9 @@ export const mutations = {
|
||||
setPlayerPlaying(state, val) {
|
||||
state.playerIsPlaying = val
|
||||
},
|
||||
setPlayerFullscreen(state, val) {
|
||||
state.playerIsFullscreen = val
|
||||
},
|
||||
setHasStoragePermission(state, val) {
|
||||
state.hasStoragePermission = val
|
||||
},
|
||||
@@ -97,6 +126,9 @@ export const mutations = {
|
||||
state.networkConnected = val.connected
|
||||
state.networkConnectionType = val.connectionType
|
||||
},
|
||||
setIsNetworkUnmetered(state, val) {
|
||||
state.isNetworkUnmetered = val
|
||||
},
|
||||
openReader(state, libraryItem) {
|
||||
state.selectedLibraryItem = libraryItem
|
||||
state.showReader = true
|
||||
@@ -110,5 +142,8 @@ export const mutations = {
|
||||
setServerSettings(state, val) {
|
||||
state.serverSettings = val
|
||||
this.$localStore.setServerSettings(state.serverSettings)
|
||||
},
|
||||
setLastLocalMediaSyncResults(state, val) {
|
||||
state.lastLocalMediaSyncResults = val
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export const mutations = {
|
||||
},
|
||||
reset(state) {
|
||||
state.lastLoad = 0
|
||||
state.currentLibraryId = null
|
||||
state.libraries = []
|
||||
},
|
||||
setCurrentLibrary(state, val) {
|
||||
|
||||
@@ -22,6 +22,12 @@ export const getters = {
|
||||
getServerAddress: (state) => {
|
||||
return state.serverConnectionConfig ? state.serverConnectionConfig.address : null
|
||||
},
|
||||
getServerConfigName: (state) => {
|
||||
return state.serverConnectionConfig ? state.serverConnectionConfig.name : null
|
||||
},
|
||||
getCustomHeaders: (state) => {
|
||||
return state.serverConnectionConfig ? state.serverConnectionConfig.customHeaders : null
|
||||
},
|
||||
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
|
||||
if (!state.user || !state.user.mediaProgress) return null
|
||||
return state.user.mediaProgress.find(li => {
|
||||
|
||||
@@ -33,6 +33,18 @@ module.exports = {
|
||||
sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans],
|
||||
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
||||
book: ['Gentium Book Basic', 'serif']
|
||||
},
|
||||
fontSize: {
|
||||
xxs: '0.625rem'
|
||||
},
|
||||
maxWidth: {
|
||||
'24': '6rem'
|
||||
},
|
||||
minWidth: {
|
||||
'12': '3rem'
|
||||
},
|
||||
minHeight: {
|
||||
'12': '3rem'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user