Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91dbd73d45 | ||
|
|
a2ac0e29e4 | ||
|
|
4271d832f1 | ||
|
|
64147d065c | ||
|
|
fb03c8e67f | ||
|
|
75979960ab | ||
|
|
1411157bde | ||
|
|
eb7289c150 | ||
|
|
c1f803bdd0 | ||
|
|
ba1efedd79 | ||
|
|
ac10997ed7 | ||
|
|
3c6f29bf3a | ||
|
|
f623393f8d | ||
|
|
66ab402a50 | ||
|
|
2076b93e19 | ||
|
|
a5aae3fce8 | ||
|
|
eb7a241e94 | ||
|
|
14ff9097c7 | ||
|
|
79f09b334c | ||
|
|
f5d1e992ef | ||
|
|
aed2c31f5a | ||
|
|
3e31e72734 | ||
|
|
7cf36d829a | ||
|
|
268cf67576 | ||
|
|
5c76158729 | ||
|
|
8c87b31e56 | ||
|
|
01678f2c91 | ||
|
|
b5e33b1707 | ||
|
|
bba388aefa | ||
|
|
46623d70a3 | ||
|
|
452b25057e | ||
|
|
67a6aec132 | ||
|
|
10ddc1c911 | ||
|
|
2a2ebefeb9 | ||
|
|
d5f39e5cb1 | ||
|
|
099be648bf | ||
|
|
6a885b7241 | ||
|
|
15cdff5aa2 | ||
|
|
8bd0e863ae | ||
|
|
94e261d7bf | ||
|
|
06f87d24a7 | ||
|
|
cdd2f40ef9 | ||
|
|
2448b461f0 | ||
|
|
4f85827585 | ||
|
|
fe042f3f83 | ||
|
|
d084958f2d | ||
|
|
ccecba7a19 | ||
|
|
d57fe44bcc | ||
|
|
a32bd9d74c | ||
|
|
39250b56c0 | ||
|
|
1c85e21e4f | ||
|
|
d331e1f9ab | ||
|
|
d7c414f6c0 | ||
|
|
523f861f77 | ||
|
|
8a0a6f383f | ||
|
|
b8b2d73eab | ||
|
|
683b4e753a | ||
|
|
a3f2b02473 | ||
|
|
befe62f8a0 | ||
|
|
621a493c5b | ||
|
|
8952cbfd20 | ||
|
|
a576eff700 | ||
|
|
506e18a137 | ||
|
|
27d2ed2304 | ||
|
|
a670ba9f95 | ||
|
|
2321312427 | ||
|
|
85382d2bb4 | ||
|
|
7745ccefab | ||
|
|
872931f507 | ||
|
|
f335d8735f | ||
|
|
fca0fd7bbb | ||
|
|
a461ed43de | ||
|
|
0f8672fb92 | ||
|
|
972fbd42ee | ||
|
|
062a217946 | ||
|
|
a59f3bb957 | ||
|
|
93cb27d324 | ||
|
|
b4e70da1f5 | ||
|
|
bf46c46fc0 | ||
|
|
af835f2c43 | ||
|
|
ffd0d4da7d | ||
|
|
e9226671c9 | ||
|
|
5fd3f3c080 | ||
|
|
519969eee0 | ||
|
|
1746ed6469 | ||
|
|
2912d442b5 | ||
|
|
a82474cf45 | ||
|
|
56a3d8bb72 | ||
|
|
add64249cd | ||
|
|
d3172ece9b | ||
|
|
f655f89c49 | ||
|
|
911e46c4f0 | ||
|
|
ec4647e2bc | ||
|
|
10f2da9e90 | ||
|
|
6aa0f2253b | ||
|
|
7c5ee940d3 | ||
|
|
d83e04c47b | ||
|
|
b0905d0270 | ||
|
|
8ce0d9ce56 | ||
|
|
ac2a1ce079 | ||
|
|
5d31499fd6 | ||
|
|
4c8217edf6 | ||
|
|
934a07a5ad | ||
|
|
7aae1edf3c | ||
|
|
7cc35e4b96 | ||
|
|
1f8a8d1ce5 | ||
|
|
e0e2f02e0b | ||
|
|
c029e519e9 | ||
|
|
ef661bba37 | ||
|
|
8d38f3358e | ||
|
|
f4e39ec7ca | ||
|
|
72744dcd29 | ||
|
|
6b374d923b | ||
|
|
03a47b06ee | ||
|
|
77d0974782 | ||
|
|
95e070a897 | ||
|
|
c32c77b963 | ||
|
|
9477860bca | ||
|
|
a5f483f518 | ||
|
|
65fcc45bc6 | ||
|
|
2e6946e1f7 | ||
|
|
64707b0928 | ||
|
|
a107a25e43 | ||
|
|
0a989e7811 | ||
|
|
4e63d43679 | ||
|
|
60cb5f398c | ||
|
|
8edeefc1cd | ||
|
|
02eabb82c1 | ||
|
|
999d434fe9 | ||
|
|
2b07c81e54 | ||
|
|
5f4ff38035 | ||
|
|
e52a5fd585 | ||
|
|
599a8e23cb | ||
|
|
81a4e4ff7b | ||
|
|
7d730dcafb | ||
|
|
446e54cb91 | ||
|
|
b9f428fd77 | ||
|
|
94e8978ce6 | ||
|
|
62376871e3 | ||
|
|
40b731534c | ||
|
|
1aea0a18a9 | ||
|
|
b1be594932 | ||
|
|
a9d7fbc083 | ||
|
|
a3e458fcc4 | ||
|
|
c1b8f8519f | ||
|
|
34d4a0b61b | ||
|
|
7c3b6c9702 | ||
|
|
e275aa1699 | ||
|
|
e9961f64a9 | ||
|
|
8e2be4704e | ||
|
|
948cd3068a | ||
|
|
162eb9afad | ||
|
|
30ae98c3de | ||
|
|
b1b10e846d | ||
|
|
56c73c12db | ||
|
|
ad802c16ea | ||
|
|
afc04d22bf | ||
|
|
b78f004f29 | ||
|
|
849b6303e8 | ||
|
|
4a82ba7c86 | ||
|
|
5b7fcca800 | ||
|
|
b549528e23 | ||
|
|
719db8d3f6 | ||
|
|
db98492d4f | ||
|
|
5430c10424 | ||
|
|
5495bcb945 | ||
|
|
7fded5e105 | ||
|
|
dc8bc2fccd | ||
|
|
af2c609405 | ||
|
|
d5d65e244b | ||
|
|
92a31c246c | ||
|
|
9082b76707 | ||
|
|
33041608f8 | ||
|
|
408faf3665 | ||
|
|
be6c675ded | ||
|
|
528854b285 | ||
|
|
87e277fad1 | ||
|
|
014588180e | ||
|
|
e02edb9f9e | ||
|
|
37fa931672 | ||
|
|
a7424cc428 | ||
|
|
837df329e2 | ||
|
|
f6c43e479d | ||
|
|
0b46a9c9b1 | ||
|
|
76de92fe1f | ||
|
|
32550a75ec | ||
|
|
9eca03cfd7 | ||
|
|
fec1ec554b | ||
|
|
db7a8cef77 | ||
|
|
5662c1983a | ||
|
|
8cfefdcc0f | ||
|
|
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 | ||
|
|
2ab9dbc72d | ||
|
|
52d0890032 | ||
|
|
b7725c455b | ||
|
|
175e642081 | ||
|
|
e620f53705 | ||
|
|
f56561b898 | ||
|
|
a29a7669e1 | ||
|
|
29f08a5e5d | ||
|
|
f40da562a6 | ||
|
|
affcdea09b | ||
|
|
16ca7460f4 | ||
|
|
2ca9ce797d |
@@ -21,7 +21,11 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
kotlinOptions {
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
freeCompilerArgs = ['-Xjvm-default=all']
|
freeCompilerArgs = ['-Xjvm-default=all']
|
||||||
}
|
}
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||||
@@ -29,8 +33,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 83
|
versionCode 88
|
||||||
versionName "0.9.52-beta"
|
versionName "0.9.57-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -111,13 +115,13 @@ dependencies {
|
|||||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||||
|
|
||||||
// Simple Storage
|
// Simple Storage
|
||||||
implementation "com.anggrayudi:storage:1.3.0"
|
implementation "com.anggrayudi:storage:0.14.0"
|
||||||
|
|
||||||
// OK HTTP
|
// OK HTTP
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||||
|
|
||||||
// Jackson for JSON
|
// Jackson for JSON
|
||||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
|
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||||
|
|
||||||
// FFMPEG-Kit
|
// FFMPEG-Kit
|
||||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
||||||
|
|||||||
@@ -1,83 +1,94 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:installLocation="preferExternal"
|
package="com.audiobookshelf.app"
|
||||||
package="com.audiobookshelf.app">
|
android:installLocation="preferExternal" >
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true" >
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
<!-- <receiver-->
|
||||||
android:requestLegacyExternalStorage="true">
|
<!-- 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-->
|
<!-- Used by Android Auto -->
|
||||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
|
||||||
android:resource="@drawable/icon" />
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||||
android:resource="@xml/automotive_app_desc"/>
|
android:resource="@drawable/icon" />
|
||||||
|
<meta-data
|
||||||
<!-- Support for Cast -->
|
android:name="com.google.android.gms.car.application"
|
||||||
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
|
||||||
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
|
android:value="com.audiobookshelf.app.CastOptionsProvider" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
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:exported="true"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:label="@string/title_activity_main"
|
||||||
android:launchMode="singleTask">
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch" >
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<intent-filter>
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<action android:name="android.intent.action.MAIN" />
|
</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>
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
</intent-filter>
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Register URL scheme -->
|
<!-- Register URL scheme -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:scheme="@string/custom_url_scheme" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
</intent-filter>
|
|
||||||
|
<data android:scheme="@string/custom_url_scheme" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</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" >
|
<service
|
||||||
<intent-filter>
|
android:name=".player.PlayerNotificationService"
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
android:enabled="true"
|
||||||
</intent-filter>
|
android:exported="true" >
|
||||||
</receiver>
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
<service
|
</intent-filter>
|
||||||
android:exported="true"
|
</service>
|
||||||
android:enabled="true"
|
|
||||||
android:name=".player.PlayerNotificationService">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,71 +1,11 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.net.Uri
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import com.audiobookshelf.app.R
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.fasterxml.jackson.annotation.*
|
import com.fasterxml.jackson.annotation.*
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LibraryItem(
|
|
||||||
var id:String,
|
|
||||||
var ino:String,
|
|
||||||
var libraryId:String,
|
|
||||||
var folderId:String,
|
|
||||||
var path:String,
|
|
||||||
var relPath:String,
|
|
||||||
var mtimeMs:Long,
|
|
||||||
var ctimeMs:Long,
|
|
||||||
var birthtimeMs:Long,
|
|
||||||
var addedAt:Long,
|
|
||||||
var updatedAt:Long,
|
|
||||||
var lastScan:Long?,
|
|
||||||
var scanVersion:String?,
|
|
||||||
var isMissing:Boolean,
|
|
||||||
var isInvalid:Boolean,
|
|
||||||
var mediaType:String,
|
|
||||||
var media:MediaType,
|
|
||||||
var libraryFiles:MutableList<LibraryFile>?,
|
|
||||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
|
||||||
) : LibraryItemWrapper() {
|
|
||||||
@get:JsonIgnore
|
|
||||||
val title get() = media.metadata.title
|
|
||||||
@get:JsonIgnore
|
|
||||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCoverUri():Uri {
|
|
||||||
if (media.coverPath == null) {
|
|
||||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun checkHasTracks():Boolean {
|
|
||||||
return if (mediaType == "podcast") {
|
|
||||||
((media as Podcast).numEpisodes ?: 0) > 0
|
|
||||||
} else {
|
|
||||||
((media as Book).numTracks ?: 0) > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This auto-detects whether it is a Book or Podcast
|
// This auto-detects whether it is a Book or Podcast
|
||||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||||
@JsonSubTypes(
|
@JsonSubTypes(
|
||||||
@@ -123,7 +63,7 @@ class Podcast(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||||
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -144,7 +84,7 @@ class Podcast(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
val newEpisode = PodcastEpisode("local_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -295,25 +235,51 @@ data class PodcastEpisode(
|
|||||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat {
|
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
var coverUri:Uri = Uri.EMPTY
|
val coverUri = if(libraryItem is LocalLibraryItem) {
|
||||||
val podcast = if(libraryItem is LocalLibraryItem) {
|
libraryItem.getCoverUri()
|
||||||
coverUri = libraryItem.getCoverUri()
|
|
||||||
libraryItem.media as Podcast
|
|
||||||
} else {
|
} else {
|
||||||
coverUri = (libraryItem as LibraryItem).getCoverUri()
|
(libraryItem as LibraryItem).getCoverUri()
|
||||||
(libraryItem as LibraryItem).media as Podcast
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
val extras = Bundle()
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
if (progress != null) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
if (progress.isFinished) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
extras.putInt(
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, podcast.metadata.getAuthorDisplayName())
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
)
|
||||||
|
} else {
|
||||||
}.build()
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// return MediaMetadataCompat.Builder().apply {
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||||
|
//
|
||||||
|
// }.build()
|
||||||
|
val libraryItemDescription = libraryItem.getMediaDescription(null)
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(libraryItemDescription.title)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,18 +373,25 @@ data class BookChapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class MediaProgress(
|
class MediaProgress(
|
||||||
var id:String,
|
var id:String,
|
||||||
var libraryItemId:String,
|
var libraryItemId:String,
|
||||||
var episodeId:String?,
|
var episodeId:String?,
|
||||||
var duration:Double, // seconds
|
var duration:Double, // seconds
|
||||||
var progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
var currentTime:Double,
|
var currentTime:Double,
|
||||||
var isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?
|
var finishedAt:Long?
|
||||||
|
) : MediaProgressWrapper(isFinished, progress)
|
||||||
|
|
||||||
|
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
|
||||||
|
@JsonSubTypes(
|
||||||
|
JsonSubTypes.Type(MediaProgress::class),
|
||||||
|
JsonSubTypes.Type(LocalMediaProgress::class)
|
||||||
)
|
)
|
||||||
|
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double)
|
||||||
|
|
||||||
// Helper class
|
// Helper class
|
||||||
data class LibraryItemWithEpisode(
|
data class LibraryItemWithEpisode(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||||
@@ -18,20 +19,28 @@ data class ServerConnectionConfig(
|
|||||||
|
|
||||||
data class DeviceSettings(
|
data class DeviceSettings(
|
||||||
var disableAutoRewind:Boolean,
|
var disableAutoRewind:Boolean,
|
||||||
|
var enableAltView:Boolean,
|
||||||
var jumpBackwardsTime:Int,
|
var jumpBackwardsTime:Int,
|
||||||
var jumpForwardTime:Int
|
var jumpForwardTime:Int,
|
||||||
|
var disableShakeToResetSleepTimer:Boolean
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
// Static method to get default device settings
|
// Static method to get default device settings
|
||||||
fun default():DeviceSettings {
|
fun default():DeviceSettings {
|
||||||
return DeviceSettings(false, 10, 10)
|
return DeviceSettings(
|
||||||
|
disableAutoRewind = false,
|
||||||
|
enableAltView = false,
|
||||||
|
jumpBackwardsTime = 10,
|
||||||
|
jumpForwardTime = 10,
|
||||||
|
disableShakeToResetSleepTimer = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpBackwardsTime) * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DeviceData(
|
data class DeviceData(
|
||||||
@@ -84,7 +93,10 @@ data class LocalFolder(
|
|||||||
JsonSubTypes.Type(LibraryItem::class),
|
JsonSubTypes.Type(LibraryItem::class),
|
||||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||||
)
|
)
|
||||||
open class LibraryItemWrapper()
|
open class LibraryItemWrapper(var id:String) {
|
||||||
|
@JsonIgnore
|
||||||
|
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Used in Android Auto to represent a podcast episode or an audiobook in progress
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ItemInProgress(
|
||||||
|
val libraryItemWrapper: LibraryItemWrapper,
|
||||||
|
val episode: PodcastEpisode?,
|
||||||
|
val progressLastUpdate: Long,
|
||||||
|
val isLocal: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||||
|
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||||
|
|
||||||
|
var episode:PodcastEpisode? = null
|
||||||
|
if (serverItem.has("recentEpisode")) {
|
||||||
|
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
|
||||||
|
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryItem(
|
||||||
|
id:String,
|
||||||
|
var ino:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var folderId:String,
|
||||||
|
var path:String,
|
||||||
|
var relPath:String,
|
||||||
|
var mtimeMs:Long,
|
||||||
|
var ctimeMs:Long,
|
||||||
|
var birthtimeMs:Long,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var lastScan:Long?,
|
||||||
|
var scanVersion:String?,
|
||||||
|
var isMissing:Boolean,
|
||||||
|
var isInvalid:Boolean,
|
||||||
|
var mediaType:String,
|
||||||
|
var media:MediaType,
|
||||||
|
var libraryFiles:MutableList<LibraryFile>?,
|
||||||
|
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = media.metadata.title
|
||||||
|
@get:JsonIgnore
|
||||||
|
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCoverUri(): Uri {
|
||||||
|
if (media.coverPath == null) {
|
||||||
|
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun checkHasTracks():Boolean {
|
||||||
|
return if (mediaType == "podcast") {
|
||||||
|
((media as Podcast).numEpisodes ?: 0) > 0
|
||||||
|
} else {
|
||||||
|
((media as Book).numTracks ?: 0) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle(authorName)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
@@ -11,8 +12,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalLibraryItem(
|
class LocalLibraryItem(
|
||||||
var id:String,
|
id:String,
|
||||||
var folderId:String,
|
var folderId:String,
|
||||||
var basePath:String,
|
var basePath:String,
|
||||||
var absolutePath:String,
|
var absolutePath:String,
|
||||||
@@ -29,7 +30,7 @@ data class LocalLibraryItem(
|
|||||||
var serverAddress:String?,
|
var serverAddress:String?,
|
||||||
var serverUserId:String?,
|
var serverUserId:String?,
|
||||||
var libraryItemId:String?
|
var libraryItemId:String?
|
||||||
) : LibraryItemWrapper() {
|
) : LibraryItemWrapper(id) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val title get() = media.metadata.title
|
val title get() = media.metadata.title
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
@@ -95,18 +96,38 @@ data class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadata(ctx: Context): MediaMetadataCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
val coverUri = getCoverUri()
|
val coverUri = getCoverUri()
|
||||||
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
val extras = Bundle()
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
if (progress != null) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
if (progress.isFinished) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
extras.putInt(
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
} else {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
extras.putInt(
|
||||||
}.build()
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(authorName)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalMediaProgress(
|
class LocalMediaProgress(
|
||||||
var id:String,
|
var id:String,
|
||||||
var localLibraryItemId:String,
|
var localLibraryItemId:String,
|
||||||
var localEpisodeId:String?,
|
var localEpisodeId:String?,
|
||||||
var duration:Double,
|
var duration:Double,
|
||||||
var progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
var currentTime:Double,
|
var currentTime:Double,
|
||||||
var isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?,
|
var finishedAt:Long?,
|
||||||
@@ -22,7 +22,7 @@ data class LocalMediaProgress(
|
|||||||
var serverUserId:String?,
|
var serverUserId:String?,
|
||||||
var libraryItemId:String?,
|
var libraryItemId:String?,
|
||||||
var episodeId:String?
|
var episodeId:String?
|
||||||
) {
|
) : MediaProgressWrapper(isFinished, progress) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getTrackStartOffsetMs(index:Int):Long {
|
fun getTrackStartOffsetMs(index:Int):Long {
|
||||||
|
if (index < 0 || index >= audioTracks.size) return 0L
|
||||||
val currentTrack = audioTracks[index]
|
val currentTrack = audioTracks[index]
|
||||||
return (currentTrack.startOffset * 1000L).toLong()
|
return (currentTrack.startOffset * 1000L).toLong()
|
||||||
}
|
}
|
||||||
@@ -123,8 +124,6 @@ class PlaybackSession(
|
|||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
return metadataBuilder.build()
|
return metadataBuilder.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.audiobookshelf.app.device
|
package com.audiobookshelf.app.device
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.DbManager
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.data.DeviceData
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.data.ServerConnectionConfig
|
|
||||||
|
interface WidgetEventEmitter {
|
||||||
|
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||||
|
}
|
||||||
|
|
||||||
object DeviceManager {
|
object DeviceManager {
|
||||||
const val tag = "DeviceManager"
|
const val tag = "DeviceManager"
|
||||||
@@ -19,6 +22,8 @@ object DeviceManager {
|
|||||||
val isConnectedToServer get() = serverConnectionConfig != null
|
val isConnectedToServer get() = serverConnectionConfig != null
|
||||||
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
|
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
|
||||||
|
|
||||||
|
var widgetUpdater:WidgetEventEmitter? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Log.d(tag, "Device Manager Singleton invoked")
|
Log.d(tag, "Device Manager Singleton invoked")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.fasterxml.jackson.core.json.JsonReadFeature
|
|||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
|
import org.json.JSONException
|
||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
class FolderScanner(var ctx: Context) {
|
||||||
private val tag = "FolderScanner"
|
private val tag = "FolderScanner"
|
||||||
@@ -465,11 +466,17 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||||
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||||
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
|
|
||||||
|
|
||||||
val probeObject = JSObject(session.output)
|
var probeObject:JSObject? = null
|
||||||
if (!probeObject.has("streams")) { // Check if output is empty
|
try {
|
||||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath is empty")
|
probeObject = JSObject(session.output)
|
||||||
|
} catch(error:JSONException) {
|
||||||
|
Log.e(tag, "Failed to parse probe result $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "FFprobe output $probeObject")
|
||||||
|
if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||||
|
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||||
|
|||||||
@@ -18,16 +18,19 @@ import kotlin.coroutines.suspendCoroutine
|
|||||||
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||||
val tag = "MediaManager"
|
val tag = "MediaManager"
|
||||||
|
|
||||||
var serverLibraryItems = mutableListOf<LibraryItem>()
|
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||||
|
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||||
var selectedLibraryId = ""
|
var selectedLibraryId = ""
|
||||||
|
|
||||||
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
|
|
||||||
var selectedPodcast:Podcast? = null
|
var selectedPodcast:Podcast? = null
|
||||||
var selectedLibraryItemId:String? = null
|
var selectedLibraryItemId:String? = null
|
||||||
var serverPodcastEpisodes = listOf<PodcastEpisode>()
|
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||||
|
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||||
var serverLibraries = listOf<Library>()
|
var serverLibraries = listOf<Library>()
|
||||||
var serverConfigIdUsed:String? = null
|
var serverConfigIdUsed:String? = null
|
||||||
|
var serverConfigLastPing:Long = 0L
|
||||||
|
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||||
|
|
||||||
var userSettingsPlaybackRate:Float? = null
|
var userSettingsPlaybackRate:Float? = null
|
||||||
|
|
||||||
@@ -64,34 +67,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||||
|
|
||||||
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||||
serverPodcastEpisodes = listOf()
|
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||||
serverLibraryCategories = listOf()
|
serverLibraryCategories = listOf()
|
||||||
serverLibraries = listOf()
|
serverLibraries = listOf()
|
||||||
serverLibraryItems = mutableListOf()
|
serverLibraryItems = mutableListOf()
|
||||||
|
selectedLibraryItems = mutableListOf()
|
||||||
selectedLibraryId = ""
|
selectedLibraryId = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
if (serverLibraryCategories.isNotEmpty()) {
|
if (serverItemsInProgress.isNotEmpty()) {
|
||||||
cb(serverLibraryCategories)
|
cb(serverItemsInProgress)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getLibraryCategories(libraryId) {
|
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||||
serverLibraryCategories = it
|
serverItemsInProgress = itemsInProgress
|
||||||
cb(it)
|
cb(serverItemsInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
||||||
cb(serverLibraryItems)
|
cb(selectedLibraryItems)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
||||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||||
if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId
|
if (libraryItemsWithAudio.isNotEmpty()) {
|
||||||
|
selectedLibraryId = libraryId
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLibraryItems = mutableListOf()
|
||||||
libraryItemsWithAudio.forEach { libraryItem ->
|
libraryItemsWithAudio.forEach { libraryItem ->
|
||||||
|
selectedLibraryItems.add(libraryItem)
|
||||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
serverLibraryItems.add(libraryItem)
|
serverLibraryItems.add(libraryItem)
|
||||||
}
|
}
|
||||||
@@ -117,37 +125,40 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||||
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||||
|
|
||||||
selectedLibraryItemWrapper = libraryItemWrapper
|
|
||||||
|
|
||||||
libraryItemWrapper?.let {
|
libraryItemWrapper?.let {
|
||||||
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
||||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||||
serverPodcastEpisodes = listOf()
|
|
||||||
cb(mutableListOf())
|
cb(mutableListOf())
|
||||||
} else {
|
} else {
|
||||||
val podcast = libraryItemWrapper.media as Podcast
|
val podcast = libraryItemWrapper.media as Podcast
|
||||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
|
||||||
selectedLibraryItemId = libraryItemWrapper.id
|
selectedLibraryItemId = libraryItemWrapper.id
|
||||||
selectedPodcast = podcast
|
selectedPodcast = podcast
|
||||||
|
|
||||||
val children = podcast.episodes?.map { podcastEpisode ->
|
val children = podcast.episodes?.map { podcastEpisode ->
|
||||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||||
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
|
||||||
|
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||||
|
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||||
}
|
}
|
||||||
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
||||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||||
serverPodcastEpisodes = listOf()
|
|
||||||
cb(mutableListOf())
|
cb(mutableListOf())
|
||||||
} else {
|
} else {
|
||||||
val podcast = libraryItemWrapper.media as Podcast
|
val podcast = libraryItemWrapper.media as Podcast
|
||||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
podcast.episodes?.forEach { podcastEpisode ->
|
||||||
|
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
|
||||||
|
}
|
||||||
selectedLibraryItemId = libraryItemWrapper.id
|
selectedLibraryItemId = libraryItemWrapper.id
|
||||||
selectedPodcast = podcast
|
selectedPodcast = podcast
|
||||||
|
|
||||||
val children = podcast.episodes?.map { podcastEpisode ->
|
val children = podcast.episodes?.map { podcastEpisode ->
|
||||||
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
|
||||||
|
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||||
|
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||||
}
|
}
|
||||||
@@ -179,16 +190,49 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
return successfulPing
|
return successfulPing
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
||||||
if (!apiHandler.isOnline()) cb(false)
|
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||||
else {
|
suspendCoroutine<MutableList<MediaProgress>> { cont ->
|
||||||
coroutineScope {
|
apiHandler.authorize(config) {
|
||||||
var hasValidConn = false
|
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
|
||||||
|
if (!it.isNullOrEmpty()) {
|
||||||
|
mediaProgress = it
|
||||||
|
}
|
||||||
|
cont.resume(mediaProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mediaProgress
|
||||||
|
}
|
||||||
|
|
||||||
// First check if the current selected config is pingable
|
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||||
DeviceManager.serverConnectionConfig?.let {
|
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||||
hasValidConn = checkServerConnection(it)
|
|
||||||
Log.d(tag, "checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn")
|
coroutineScope {
|
||||||
|
if (!apiHandler.isOnline()) {
|
||||||
|
serverUserMediaProgress = mutableListOf()
|
||||||
|
cb(false)
|
||||||
|
} else {
|
||||||
|
|
||||||
|
var hasValidConn = false
|
||||||
|
var lookupMediaProgress = true
|
||||||
|
|
||||||
|
if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
|
||||||
|
Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
|
||||||
|
hasValidConn = true
|
||||||
|
lookupMediaProgress = false
|
||||||
|
} else {
|
||||||
|
serverUserMediaProgress = mutableListOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidConn) {
|
||||||
|
// First check if the current selected config is pingable
|
||||||
|
DeviceManager.serverConnectionConfig?.let {
|
||||||
|
hasValidConn = checkServerConnection(it)
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasValidConn) {
|
if (!hasValidConn) {
|
||||||
@@ -199,36 +243,31 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
if (result) {
|
if (result) {
|
||||||
hasValidConn = true
|
hasValidConn = true
|
||||||
DeviceManager.serverConnectionConfig = config
|
DeviceManager.serverConnectionConfig = config
|
||||||
|
Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasValidConn) {
|
||||||
|
serverConfigLastPing = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (lookupMediaProgress) {
|
||||||
|
Log.d(tag, "Has valid conn now get user media progress")
|
||||||
|
DeviceManager.serverConnectionConfig?.let {
|
||||||
|
serverUserMediaProgress = authorize(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cb(hasValidConn)
|
cb(hasValidConn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Load currently listening category for local items
|
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||||
fun loadLocalCategory():List<LibraryCategory> {
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
|
||||||
val cats = mutableListOf<LibraryCategory>()
|
|
||||||
if (localBooks.isNotEmpty()) {
|
|
||||||
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
|
|
||||||
}
|
|
||||||
if (localPodcasts.isNotEmpty()) {
|
|
||||||
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
|
|
||||||
}
|
|
||||||
return cats
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
|
|
||||||
Log.d(tag, "Load android auto items")
|
Log.d(tag, "Load android auto items")
|
||||||
val cats = mutableListOf<LibraryCategory>()
|
|
||||||
|
|
||||||
val localCategories = loadLocalCategory()
|
|
||||||
cats.addAll(localCategories)
|
|
||||||
|
|
||||||
// Check if any valid server connection if not use locally downloaded books
|
// Check if any valid server connection if not use locally downloaded books
|
||||||
checkSetValidServerConnectionConfig { isConnected ->
|
checkSetValidServerConnectionConfig { isConnected ->
|
||||||
@@ -236,36 +275,31 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||||
|
|
||||||
loadLibraries { libraries ->
|
loadLibraries { libraries ->
|
||||||
val library = libraries[0]
|
if (libraries.isEmpty()) {
|
||||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
Log.w(tag, "No libraries returned from server request")
|
||||||
|
cb()
|
||||||
|
} else {
|
||||||
|
val library = libraries[0]
|
||||||
|
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
||||||
|
|
||||||
loadLibraryCategories(library.id) { libraryCategories ->
|
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
||||||
|
itemsInProgress.forEach {
|
||||||
|
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
// Only using book or podcast library categories for now
|
if (it.episode != null) {
|
||||||
libraryCategories.forEach {
|
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
||||||
|
|
||||||
// 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}")
|
cb() // Fully loaded
|
||||||
if (it.type == library.mediaType) {
|
|
||||||
// Log.d(tag, "Using library category ${it.id}")
|
|
||||||
cats.add(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cb(cats)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // Not connected/no internet sent downloaded cats only
|
} else { // Not connected to server
|
||||||
cb(cats)
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,12 +317,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
if (id.startsWith("local")) {
|
if (id.startsWith("local")) {
|
||||||
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||||
} else {
|
} else {
|
||||||
val podcastEpisode = serverPodcastEpisodes.find { it.id == id }
|
return podcastEpisodeLibraryItemMap[id]
|
||||||
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
|
|
||||||
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,11 @@ import android.net.Uri
|
|||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import androidx.annotation.AnyRes
|
import androidx.annotation.AnyRes
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.data.Library
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.data.LibraryCategory
|
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
|
||||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
|
||||||
|
|
||||||
class BrowseTree(
|
class BrowseTree(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
libraryCategories: List<LibraryCategory>,
|
itemsInProgress: List<ItemInProgress>,
|
||||||
libraries: List<Library>
|
libraries: List<Library>
|
||||||
) {
|
) {
|
||||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||||
@@ -24,8 +21,7 @@ class BrowseTree(
|
|||||||
* @param drawableId - drawable res id
|
* @param drawableId - drawable res id
|
||||||
* @return - uri
|
* @return - uri
|
||||||
*/
|
*/
|
||||||
fun getUriToDrawable(context: Context,
|
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||||
@AnyRes drawableId: Int): Uri {
|
|
||||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||||
@@ -35,38 +31,26 @@ class BrowseTree(
|
|||||||
init {
|
init {
|
||||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||||
|
|
||||||
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
|
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
// Server continue Listening cat
|
if (!itemsInProgress.isEmpty()) {
|
||||||
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
|
rootList += continueListeningMetadata
|
||||||
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
|
|
||||||
val libraryItem = liw as LibraryItem
|
|
||||||
libraryItem.getMediaMetadata()
|
|
||||||
}
|
|
||||||
if (continueListeningMediaMetadata.isNotEmpty()) {
|
|
||||||
rootList += continueReadingMetadata
|
|
||||||
}
|
|
||||||
continueListeningMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
|
||||||
children += it
|
|
||||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraries.isNotEmpty()) {
|
if (libraries.isNotEmpty()) {
|
||||||
@@ -81,23 +65,6 @@ class BrowseTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootList += downloadsMetadata
|
rootList += downloadsMetadata
|
||||||
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
|
|
||||||
localBooksCat.entities.forEach { libc ->
|
|
||||||
val libraryItem = libc as LocalLibraryItem
|
|
||||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
|
||||||
children += libraryItem.getMediaMetadata(context)
|
|
||||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
|
|
||||||
localPodcastsCat.entities.forEach { libc ->
|
|
||||||
val libraryItem = libc as LocalLibraryItem
|
|
||||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
|
||||||
children += libraryItem.getMediaMetadata(context)
|
|
||||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ data class MediaProgressSyncData(
|
|||||||
|
|
||||||
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
|
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||||
private val tag = "MediaProgressSync"
|
private val tag = "MediaProgressSync"
|
||||||
|
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||||
|
|
||||||
private var listeningTimerTask: TimerTask? = null
|
private var listeningTimerTask: TimerTask? = null
|
||||||
var listeningTimerRunning:Boolean = false
|
var listeningTimerRunning:Boolean = false
|
||||||
@@ -42,54 +43,101 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
currentLocalMediaProgress = null
|
currentLocalMediaProgress = null
|
||||||
listeningTimerTask?.cancel()
|
listeningTimerTask?.cancel()
|
||||||
lastSyncTime = 0L
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
|
||||||
|
currentLocalMediaProgress = null
|
||||||
}
|
}
|
||||||
|
|
||||||
listeningTimerRunning = true
|
listeningTimerRunning = true
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
Log.d(tag, "start: init last sync time $lastSyncTime")
|
||||||
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
|
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
|
||||||
|
|
||||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post() {
|
||||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||||
|
// 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()
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
sync(currentTime) {
|
if (currentTime > 0) {
|
||||||
Log.d(tag, "Sync complete")
|
sync(shouldSyncServer, currentTime) {
|
||||||
|
Log.d(tag, "Sync complete")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||||
if (!listeningTimerRunning) return
|
if (!listeningTimerRunning) return
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
listeningTimerTask = null
|
||||||
|
listeningTimerRunning = false
|
||||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||||
|
|
||||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||||
sync(currentTime) {
|
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||||
|
sync(true, currentTime) {
|
||||||
|
reset()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
reset()
|
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) {
|
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
it.updatedAt = mediaProgress.lastUpdate
|
it.updatedAt = mediaProgress.lastUpdate
|
||||||
it.currentTime = mediaProgress.currentTime
|
it.currentTime = mediaProgress.currentTime
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync(currentTime:Double, cb: () -> Unit) {
|
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
|
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||||
if (diffSinceLastSync < 1000L) {
|
if (diffSinceLastSync < 1000L) {
|
||||||
return cb()
|
return cb()
|
||||||
}
|
}
|
||||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
lastSyncTime = System.currentTimeMillis()
|
|
||||||
|
|
||||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||||
|
|
||||||
@@ -105,36 +153,53 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
// Local library item is linked to a server library item
|
// Local library item is linked to a server library item
|
||||||
// Send sync to server also if connected to this server and local item belongs to this server
|
// Send sync to server also if connected to this server and local item belongs to this server
|
||||||
if (!it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||||
apiHandler.sendLocalProgressSync(it) {
|
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
|
||||||
Log.d(
|
Log.d(
|
||||||
tag,
|
tag,
|
||||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
||||||
)
|
)
|
||||||
|
if (syncSuccess) {
|
||||||
|
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()
|
cb()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (shouldSyncServer) {
|
||||||
apiHandler.sendProgressSync(currentSessionId, syncData) {
|
apiHandler.sendProgressSync(currentSessionId, syncData) {
|
||||||
if (it) {
|
if (it) {
|
||||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
|
playerNotificationService.alertSyncSuccess()
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
} else {
|
} else {
|
||||||
failedSyncs++
|
failedSyncs++
|
||||||
if (failedSyncs == 2) {
|
if (failedSyncs == 2) {
|
||||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
failedSyncs = 0
|
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()
|
cb()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +227,10 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
listeningTimerTask?.cancel()
|
|
||||||
listeningTimerTask = null
|
|
||||||
listeningTimerRunning = false
|
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
currentLocalMediaProgress = null
|
currentLocalMediaProgress = null
|
||||||
lastSyncTime = 0L
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Message
|
import android.os.Message
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
import com.audiobookshelf.app.data.PodcastEpisode
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -21,7 +19,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
|
|
||||||
private var mediaButtonClickCount: Int = 0
|
private var mediaButtonClickCount: Int = 0
|
||||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
var mediaButtonClickTimeout: Long = 1000 //ms
|
||||||
var seekAmount: Long = 20000 //ms
|
|
||||||
|
|
||||||
override fun onPrepare() {
|
override fun onPrepare() {
|
||||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||||
@@ -75,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.skipToPrevious()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
override fun onSkipToNext() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.skipToNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFastForward() {
|
override fun onFastForward() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRewind() {
|
override fun onRewind() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
@@ -132,8 +129,25 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleCallMediaButton(intent: Intent): Boolean {
|
fun handleCallMediaButton(intent: Intent): Boolean {
|
||||||
|
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
|
||||||
|
|
||||||
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
||||||
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
val keyEvent = 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) {
|
if (keyEvent?.action == KeyEvent.ACTION_UP) {
|
||||||
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
|
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
|
||||||
@@ -162,10 +176,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
playerNotificationService.closePlayback()
|
playerNotificationService.closePlayback()
|
||||||
@@ -209,22 +223,22 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
super.handleMessage(msg)
|
super.handleMessage(msg)
|
||||||
if (2 == msg.what) {
|
if (2 == msg.what) {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
else if (msg.what >= 3) {
|
else if (msg.what >= 3) {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example Using a custom action in android auto
|
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||||
// override fun onCustomAction(action: String?, extras: Bundle?) {
|
super.onCustomAction(action, extras)
|
||||||
// super.onCustomAction(action, extras)
|
|
||||||
//
|
when (action) {
|
||||||
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) {
|
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||||
//
|
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
|
||||||
|
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||||
@@ -71,32 +71,36 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
Log.d(tag, "SeekBackTime: Player is playing")
|
Log.d(tag, "SeekBackTime: Player is playing")
|
||||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||||
|
var seekBackTime = 0L
|
||||||
if (onSeekBack) onSeekBack = false
|
if (onSeekBack) onSeekBack = false
|
||||||
else {
|
else {
|
||||||
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
||||||
var backTime = calcPauseSeekBackTime()
|
seekBackTime = calcPauseSeekBackTime()
|
||||||
if (backTime > 0) {
|
if (seekBackTime > 0) {
|
||||||
// Current chapter is used so that seek back does not go back to the previous chapter
|
// Current chapter is used so that seek back does not go back to the previous chapter
|
||||||
val currentChapter = playerNotificationService.getCurrentBookChapter()
|
val currentChapter = playerNotificationService.getCurrentBookChapter()
|
||||||
val minSeekBackTime = currentChapter?.startMs ?: 0
|
val minSeekBackTime = currentChapter?.startMs ?: 0
|
||||||
|
|
||||||
val currentTime = playerNotificationService.getCurrentTime()
|
val currentTime = playerNotificationService.getCurrentTime()
|
||||||
val newTime = currentTime - backTime
|
val newTime = currentTime - seekBackTime
|
||||||
if (newTime < minSeekBackTime) {
|
if (newTime < minSeekBackTime) {
|
||||||
backTime = currentTime - minSeekBackTime
|
seekBackTime = currentTime - minSeekBackTime
|
||||||
}
|
}
|
||||||
Log.d(tag, "SeekBackTime $backTime")
|
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||||
onSeekBack = true
|
onSeekBack = true
|
||||||
playerNotificationService.seekBackward(backTime)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if playback session still exists or sync media progress if updated
|
// Check if playback session still exists or sync media progress if updated
|
||||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress()
|
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||||
if (!shouldCarryOn) return
|
if (!shouldCarryOn) return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (seekBackTime > 0L) {
|
||||||
|
playerNotificationService.seekBackward(seekBackTime)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
|
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
|
||||||
@@ -104,15 +108,18 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start/stop progress sync interval
|
// Start/stop progress sync interval
|
||||||
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
|
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
playerNotificationService.mediaProgressSyncer.start()
|
playerNotificationService.mediaProgressSyncer.start()
|
||||||
} else {
|
} else {
|
||||||
playerNotificationService.mediaProgressSyncer.stop()
|
playerNotificationService.mediaProgressSyncer.pause {
|
||||||
|
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
||||||
|
|
||||||
|
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
|||||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
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
|
// 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) {
|
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
|
||||||
Log.d(tag, "PNS is not closed - closing it now")
|
Log.d(tag, "PNS is not closed - closing it now")
|
||||||
playerNotificationService.closePlayback()
|
playerNotificationService.closePlayback()
|
||||||
|
} else if (PlayerNotificationService.isSwitchingPlayer) {
|
||||||
|
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
||||||
|
// so we don't want to cancel the playback during this switch
|
||||||
|
Log.d(tag, "PNS is switching player")
|
||||||
|
PlayerNotificationService.isSwitchingPlayer = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import android.content.Intent
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorManager
|
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.os.*
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
@@ -16,7 +20,6 @@ import android.util.Log
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
@@ -26,9 +29,11 @@ import com.audiobookshelf.app.data.DeviceInfo
|
|||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaManager
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
||||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import com.google.android.exoplayer2.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
@@ -38,6 +43,7 @@ import com.google.android.exoplayer2.upstream.*
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
|
||||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||||
|
|
||||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
@@ -45,6 +51,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
companion object {
|
companion object {
|
||||||
var isStarted = false
|
var isStarted = false
|
||||||
var isClosed = false
|
var isClosed = false
|
||||||
|
var isUnmeteredNetwork = false
|
||||||
|
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientEventEmitter {
|
interface ClientEventEmitter {
|
||||||
@@ -58,6 +66,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
fun onPlaybackFailed(errorMessage:String)
|
fun onPlaybackFailed(errorMessage:String)
|
||||||
fun onMediaPlayerChanged(mediaPlayer:String)
|
fun onMediaPlayerChanged(mediaPlayer:String)
|
||||||
fun onProgressSyncFailing()
|
fun onProgressSyncFailing()
|
||||||
|
fun onProgressSyncSuccess()
|
||||||
|
fun onNetworkMeteredChanged(isUnmetered:Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tag = "PlayerService"
|
private val tag = "PlayerService"
|
||||||
@@ -140,6 +150,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
// detach player
|
// detach player
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
try {
|
||||||
|
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||||
|
} catch(error:Exception) {
|
||||||
|
Log.e(tag, "Error unregistering network listening callback $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "onDestroy")
|
||||||
playerNotificationManager.setPlayer(null)
|
playerNotificationManager.setPlayer(null)
|
||||||
mPlayer.release()
|
mPlayer.release()
|
||||||
castPlayer?.release()
|
castPlayer?.release()
|
||||||
@@ -163,6 +181,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
ctx = this
|
ctx = this
|
||||||
|
|
||||||
|
// To listen for network change from metered to unmetered
|
||||||
|
val networkRequest = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||||
|
.build()
|
||||||
|
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||||
|
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
|
||||||
|
|
||||||
DbManager.initialize(ctx)
|
DbManager.initialize(ctx)
|
||||||
|
|
||||||
// Initialize API
|
// Initialize API
|
||||||
@@ -194,6 +221,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.apply {
|
.apply {
|
||||||
setSessionActivity(sessionActivityPendingIntent)
|
setSessionActivity(sessionActivityPendingIntent)
|
||||||
isActive = true
|
isActive = true
|
||||||
|
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
||||||
@@ -238,11 +266,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
// Fix for local images crashing on Android 11 for specific devices
|
// Fix for local images crashing on Android 11 for specific devices
|
||||||
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||||
ctx.grantUriPermission(
|
try {
|
||||||
"com.android.systemui",
|
ctx.grantUriPermission(
|
||||||
coverUri,
|
"com.android.systemui",
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
coverUri,
|
||||||
)
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
} catch(error:Exception) {
|
||||||
|
Log.e(tag, "Grant uri permission error $error")
|
||||||
|
}
|
||||||
|
|
||||||
return MediaDescriptionCompat.Builder()
|
return MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(currentPlaybackSession!!.id)
|
.setMediaId(currentPlaybackSession!!.id)
|
||||||
@@ -264,17 +296,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||||
|
|
||||||
// Example adding custom action with icon in android auto
|
mediaSessionConnector.setCustomActionProviders(
|
||||||
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider {
|
JumpForwardCustomActionProvider(),
|
||||||
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
JumpBackwardCustomActionProvider(),
|
||||||
// }
|
)
|
||||||
// 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))
|
mediaSession.setCallback(MediaSessionCallback(this))
|
||||||
|
|
||||||
@@ -290,15 +315,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
1000 * 20 // 20s playback rebuffer
|
1000 * 20 // 20s playback rebuffer
|
||||||
).build()
|
).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)
|
mPlayer = ExoPlayer.Builder(this)
|
||||||
.setLoadControl(customLoadControl)
|
.setLoadControl(customLoadControl)
|
||||||
.setSeekBackIncrementMs(seekBackTime)
|
.setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
|
||||||
.setSeekForwardIncrementMs(seekForwardTime)
|
.setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
|
||||||
.build()
|
.build()
|
||||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||||
mPlayer.addListener(PlayerListener(this))
|
mPlayer.addListener(PlayerListener(this))
|
||||||
@@ -396,7 +416,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
currentPlayer.setPlaybackSpeed(playbackRateToUse)
|
currentPlayer.setPlaybackSpeed(playbackRateToUse)
|
||||||
|
|
||||||
currentPlayer.prepare()
|
currentPlayer.prepare()
|
||||||
|
|
||||||
} else if (castPlayer != null) {
|
} else if (castPlayer != null) {
|
||||||
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
||||||
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
|
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
|
||||||
@@ -419,7 +438,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||||
if (it == null) { // Play request failed
|
if (it == null) { // Play request failed
|
||||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||||
closePlayback()
|
closePlayback(true)
|
||||||
} else {
|
} else {
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
preparePlayer(it, true, null)
|
preparePlayer(it, true, null)
|
||||||
@@ -428,7 +447,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||||
closePlayback()
|
closePlayback(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,6 +491,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentPlaybackSession == null) {
|
||||||
|
Log.e(tag, "switchToPlayer: No Current playback session")
|
||||||
|
} else {
|
||||||
|
isSwitchingPlayer = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playback session in progress syncer is a copy that is up-to-date so replace current here with that
|
||||||
|
// TODO: bad design here implemented to prevent the session in MediaProgressSyncer from changing while syncing
|
||||||
|
if (mediaProgressSyncer.currentPlaybackSession != null) {
|
||||||
|
currentPlaybackSession = mediaProgressSyncer.currentPlaybackSession?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
currentPlayer = if (useCastPlayer) {
|
currentPlayer = if (useCastPlayer) {
|
||||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
mediaSessionConnector.setPlayer(castPlayer)
|
||||||
@@ -486,15 +517,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
|
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
|
||||||
|
|
||||||
if (currentPlaybackSession == null) {
|
|
||||||
Log.d(tag, "switchToPlayer: No Current playback session")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}")
|
Log.d(tag, "switchToPlayer: Starting new playback session ${it.displayTitle}")
|
||||||
if (wasPlaying) { // media is paused when switching players
|
if (wasPlaying) { // media is paused when switching players
|
||||||
clientEventEmitter?.onPlayingUpdate(false)
|
clientEventEmitter?.onPlayingUpdate(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Start a new playback session here instead of using the existing
|
||||||
preparePlayer(it, false, null)
|
preparePlayer(it, false, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,10 +560,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return currentPlaybackSession?.totalDurationMs ?: 0L
|
return currentPlaybackSession?.totalDurationMs ?: 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentBookTitle() : String? {
|
|
||||||
return currentPlaybackSession?.displayTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
||||||
return currentPlaybackSession?.clone()
|
return currentPlaybackSession?.clone()
|
||||||
}
|
}
|
||||||
@@ -549,10 +574,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
// Called from PlayerListener play event
|
// Called from PlayerListener play event
|
||||||
// check with server if progress has updated since last play and sync progress update
|
// check with server if progress has updated since last play and sync progress update
|
||||||
fun checkCurrentSessionProgress():Boolean {
|
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
|
||||||
if (currentPlaybackSession == null) return true
|
if (currentPlaybackSession == null) return true
|
||||||
|
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||||
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
|
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
|
||||||
return true // carry on
|
return true // carry on
|
||||||
}
|
}
|
||||||
@@ -574,16 +599,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
|
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
|
||||||
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
|
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 {
|
Handler(Looper.getMainLooper()).post {
|
||||||
seekPlayer(playbackSession.currentTimeMs)
|
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 {
|
} else {
|
||||||
@@ -606,6 +643,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
} else {
|
} else {
|
||||||
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
|
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
if (seekBackTime > 0L) {
|
||||||
|
seekBackward(seekBackTime)
|
||||||
|
}
|
||||||
|
|
||||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
mediaProgressSyncer.start()
|
mediaProgressSyncer.start()
|
||||||
clientEventEmitter?.onPlayingUpdate(true)
|
clientEventEmitter?.onPlayingUpdate(true)
|
||||||
@@ -623,10 +664,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentPlayer.volume = 1F
|
currentPlayer.volume = 1F
|
||||||
if (currentPlayer == castPlayer) {
|
|
||||||
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlayer.play()
|
currentPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +693,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipToPrevious() {
|
||||||
|
currentPlayer.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipToNext() {
|
||||||
|
currentPlayer.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpForward() {
|
||||||
|
seekForward(deviceSettings.jumpForwardTimeMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpBackward() {
|
||||||
|
seekBackward(deviceSettings.jumpBackwardsTimeMs)
|
||||||
|
}
|
||||||
|
|
||||||
fun seekForward(amount: Long) {
|
fun seekForward(amount: Long) {
|
||||||
seekPlayer(getCurrentTime() + amount)
|
seekPlayer(getCurrentTime() + amount)
|
||||||
}
|
}
|
||||||
@@ -668,8 +721,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
currentPlayer.setPlaybackSpeed(speed)
|
currentPlayer.setPlaybackSpeed(speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closePlayback() {
|
fun closePlayback(calledOnError:Boolean? = false) {
|
||||||
Log.d(tag, "closePlayback")
|
Log.d(tag, "closePlayback")
|
||||||
|
if (mediaProgressSyncer.listeningTimerRunning) {
|
||||||
|
Log.i(tag, "About to close playback so stopping media progress syncer first")
|
||||||
|
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
|
||||||
|
Log.d(tag, "Media Progress syncer stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentPlayer.stop()
|
currentPlayer.stop()
|
||||||
currentPlayer.clearMediaItems()
|
currentPlayer.clearMediaItems()
|
||||||
@@ -705,6 +765,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
|
|
||||||
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||||
}
|
}
|
||||||
@@ -717,6 +780,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
clientEventEmitter?.onProgressSyncFailing()
|
clientEventEmitter?.onProgressSyncFailing()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun alertSyncSuccess() {
|
||||||
|
clientEventEmitter?.onProgressSyncSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||||
//
|
//
|
||||||
@@ -725,11 +792,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
||||||
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
||||||
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
|
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 AUTO_MEDIA_ROOT = "/"
|
||||||
private val ALL_ROOT = "__ALL__"
|
|
||||||
private val LIBRARIES_ROOT = "__LIBRARIES__"
|
private val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||||
|
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
|
private val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
private lateinit var browseTree:BrowseTree
|
private lateinit var browseTree:BrowseTree
|
||||||
|
|
||||||
|
|
||||||
@@ -772,8 +840,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
||||||
|
|
||||||
var flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
|
||||||
|
|
||||||
result.detach()
|
result.detach()
|
||||||
|
|
||||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||||
@@ -785,43 +851,67 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||||
val children = libraryItems.map { libraryItem ->
|
val children = libraryItems.map { libraryItem ->
|
||||||
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
|
|
||||||
|
|
||||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||||
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
val mediaDescription = libraryItem.getMediaDescription(null)
|
||||||
|
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
|
} else {
|
||||||
|
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||||
|
val description = libraryItem.getMediaDescription(progress)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, flag)
|
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads
|
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||||
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
|
|
||||||
localBooks.forEach { localLibraryItem ->
|
localBooks.forEach { localLibraryItem ->
|
||||||
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
|
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
val description = localLibraryItem.getMediaDescription(progress)
|
||||||
|
|
||||||
|
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
localPodcasts.forEach { localLibraryItem ->
|
localPodcasts.forEach { localLibraryItem ->
|
||||||
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
|
val mediaDescription = localLibraryItem.getMediaDescription(null)
|
||||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.sendResult(localBrowseItems)
|
result.sendResult(localBrowseItems)
|
||||||
|
|
||||||
} else { // Load categories
|
} else if (parentMediaId == CONTINUE_ROOT) {
|
||||||
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
mediaManager.loadAndroidAutoItems() { libraryCategories ->
|
mediaManager.serverItemsInProgress.forEach { itemInProgress ->
|
||||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
val progress: MediaProgressWrapper?
|
||||||
|
val mediaDescription:MediaDescriptionCompat
|
||||||
|
if (itemInProgress.episode != null) {
|
||||||
|
if (itemInProgress.isLocal) {
|
||||||
|
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||||
|
} else {
|
||||||
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||||
|
}
|
||||||
|
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
|
||||||
|
} else {
|
||||||
|
if (itemInProgress.isLocal) {
|
||||||
|
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
||||||
|
} else {
|
||||||
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
|
||||||
|
}
|
||||||
|
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
|
||||||
|
}
|
||||||
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
result.sendResult(localBrowseItems)
|
||||||
|
} else {
|
||||||
|
mediaManager.loadAndroidAutoItems {
|
||||||
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
|
|
||||||
val children = browseTree[parentMediaId]?.map { item ->
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag")
|
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
||||||
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
@@ -830,10 +920,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||||
result.detach()
|
result.detach()
|
||||||
mediaManager.loadAndroidAutoItems() { libraryCategories ->
|
mediaManager.loadAndroidAutoItems {
|
||||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
val children = browseTree[ALL_ROOT]?.map { item ->
|
val children = browseTree[LIBRARIES_ROOT]?.map { item ->
|
||||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
@@ -886,5 +976,53 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class JumpBackwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_BACKWARD,
|
||||||
|
getContext().getString(R.string.action_jump_backward),
|
||||||
|
R.drawable.exo_icon_rewind
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class JumpForwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_FORWARD,
|
||||||
|
getContext().getString(R.string.action_jump_forward),
|
||||||
|
R.drawable.exo_icon_fastforward
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -203,8 +204,13 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleShake() {
|
fun handleShake() {
|
||||||
Log.d(tag, "HANDLE SHAKE HERE")
|
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
||||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
|
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||||
|
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkShouldExtendSleepTimer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun increaseSleepTime(time: Long) {
|
fun increaseSleepTime(time: Long) {
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
override fun onProgressSyncFailing() {
|
override fun onProgressSyncFailing() {
|
||||||
emit("onProgressSyncFailing", "")
|
emit("onProgressSyncFailing", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onProgressSyncSuccess() {
|
||||||
|
emit("onProgressSyncSuccess", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
|
||||||
|
emit("onNetworkMeteredChanged", isUnmetered)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
mainActivity.pluginCallback = foregroundServiceReady
|
mainActivity.pluginCallback = foregroundServiceReady
|
||||||
@@ -173,7 +181,21 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||||
val playbackSession = it.getPlaybackSession(episode)
|
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())
|
return call.resolve(JSObject())
|
||||||
}
|
}
|
||||||
@@ -184,9 +206,20 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
if (it == null) {
|
if (it == null) {
|
||||||
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
|
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
|
||||||
playerNotificationService.preparePlayer(it, 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(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.getcapacitor.JSObject
|
|||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -43,11 +44,13 @@ class ApiHandler(var ctx:Context) {
|
|||||||
makeRequest(request, httpClient, cb)
|
makeRequest(request, httpClient, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||||
|
val address = config?.address ?: DeviceManager.serverAddress
|
||||||
|
val token = config?.token ?: DeviceManager.token
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||||
val request = Request.Builder().post(requestBody)
|
val request = Request.Builder().post(requestBody)
|
||||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
|
||||||
.build()
|
.build()
|
||||||
makeRequest(request, null, cb)
|
makeRequest(request, null, cb)
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,12 @@ class ApiHandler(var ctx:Context) {
|
|||||||
return false
|
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) {
|
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||||
val client = httpClient ?: defaultClient
|
val client = httpClient ?: defaultClient
|
||||||
client.newCall(request).enqueue(object : Callback {
|
client.newCall(request).enqueue(object : Callback {
|
||||||
@@ -180,21 +189,16 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
getRequest("/api/libraries/$libraryId/personalized", null, null) {
|
getRequest("/api/me/items-in-progress", null, null) {
|
||||||
val items = mutableListOf<LibraryCategory>()
|
val items = mutableListOf<ItemInProgress>()
|
||||||
if (it.has("value")) {
|
if (it.has("libraryItems")) {
|
||||||
val array = it.getJSONArray("value")
|
val array = it.getJSONArray("libraryItems")
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val jsobj = array.get(i) as JSONObject
|
val jsobj = array.get(i) as JSONObject
|
||||||
|
|
||||||
val type = jsobj.get("type").toString()
|
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||||
// Only support for podcast and book in android auto
|
items.add(itemInProgress)
|
||||||
if (type == "podcast" || type == "book") {
|
|
||||||
jsobj.put("isLocal", false)
|
|
||||||
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
|
|
||||||
items.add(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cb(items)
|
cb(items)
|
||||||
@@ -205,7 +209,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
|
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
|
||||||
|
|
||||||
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
||||||
postRequest(endpoint, payload) {
|
postRequest(endpoint, payload, null) {
|
||||||
if (it.has("error")) {
|
if (it.has("error")) {
|
||||||
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
|
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
|
||||||
cb(null)
|
cb(null)
|
||||||
@@ -221,7 +225,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
|
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
||||||
|
|
||||||
postRequest("/api/session/$sessionId/sync", payload) {
|
postRequest("/api/session/$sessionId/sync", payload, null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
cb(false)
|
cb(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -230,11 +234,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))
|
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||||
|
|
||||||
postRequest("/api/session/local", payload) {
|
postRequest("/api/session/local", payload, null) {
|
||||||
cb()
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
|
cb(false)
|
||||||
|
} else {
|
||||||
|
cb(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +263,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
if (localMediaProgress.isNotEmpty()) {
|
if (localMediaProgress.isNotEmpty()) {
|
||||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
||||||
postRequest("/api/me/sync-local-progress", payload) {
|
postRequest("/api/me/sync-local-progress", payload, null) {
|
||||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
||||||
|
|
||||||
if (it.toString() == "{}") {
|
if (it.toString() == "{}") {
|
||||||
@@ -333,4 +341,25 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||||
|
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||||
|
postRequest("/api/authorize", JSObject(), config) {
|
||||||
|
val error = it.getString("error")
|
||||||
|
if (!error.isNullOrEmpty()) {
|
||||||
|
Log.d(tag, "authorize: Authorize ${config.address} Failed: $error")
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
val mediaProgressList:MutableList<MediaProgress> = mutableListOf()
|
||||||
|
val user = it.getJSObject("user")
|
||||||
|
val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray()
|
||||||
|
for (i in 0 until mediaProgress.length()) {
|
||||||
|
val mediaProg = jacksonMapper.readValue<MediaProgress>(mediaProgress.getJSONObject(i).toString())
|
||||||
|
mediaProgressList.add(mediaProg)
|
||||||
|
}
|
||||||
|
Log.d(tag, "authorize: Authorize ${config.address} Successful")
|
||||||
|
cb(mediaProgressList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 101 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>
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="78.5885"
|
|
||||||
android:endY="90.9159"
|
|
||||||
android:startX="48.7653"
|
|
||||||
android:startY="61.0927"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#26A69A"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 28 KiB |
@@ -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,30 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<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: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>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
@@ -4,4 +4,8 @@
|
|||||||
<string name="title_activity_main">audiobookshelf</string>
|
<string name="title_activity_main">audiobookshelf</string>
|
||||||
<string name="package_name">com.audiobookshelf.app</string>
|
<string name="package_name">com.audiobookshelf.app</string>
|
||||||
<string name="custom_url_scheme">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>
|
||||||
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
<string name="action_jump_backward">Jump Backward</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -17,6 +17,15 @@
|
|||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||||
<item name="android:background">@drawable/screen</item>
|
<!-- <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>
|
</style>
|
||||||
</resources>
|
</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"
|
||||||
|
/>
|
||||||
@@ -8,8 +8,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.google.gms:google-services:4.3.5'
|
classpath 'com.google.gms:google-services:4.3.10'
|
||||||
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
|
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ body {
|
|||||||
box-shadow: 2px 10px 8px #1111117e;
|
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
|
Bookshelf Label
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +84,14 @@ Bookshelf Label
|
|||||||
color: #fce3a6;
|
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 {
|
.cover-bg {
|
||||||
width: calc(100% + 40px);
|
width: calc(100% + 40px);
|
||||||
height: calc(100% + 40px);
|
height: calc(100% + 40px);
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-4 right-4 absolute cursor-pointer">
|
<div class="top-4 right-4 absolute cursor-pointer">
|
||||||
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction">
|
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
<span class="material-icons text-3xl">more_vert</span>
|
|
||||||
</ui-dropdown-menu>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,6 +32,10 @@
|
|||||||
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
|
<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" />
|
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
|
||||||
|
<span class="material-icons text-error text-3xl">error</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||||
@@ -62,23 +64,24 @@
|
|||||||
|
|
||||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||||
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<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="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||||
<span v-show="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>
|
<span v-show="showFullscreen && !lockUi" 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
||||||
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack">
|
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
|
||||||
</div>
|
</div>
|
||||||
<div id="timestamp-row" class="flex pt-0.5">
|
<div id="timestamp-row" class="flex pt-0.5">
|
||||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||||
@@ -91,6 +94,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
|
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
|
||||||
|
<template v-slot:chapter_track="{ item }">
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||||
|
<div class="flex items-center px-3">
|
||||||
|
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||||
|
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<template v-slot:lock="{ item }">
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||||
|
<div class="flex items-center px-3">
|
||||||
|
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
|
||||||
|
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</modals-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -129,13 +150,18 @@ export default {
|
|||||||
onPlaybackClosedListener: null,
|
onPlaybackClosedListener: null,
|
||||||
onPlayingUpdateListener: null,
|
onPlayingUpdateListener: null,
|
||||||
onMetadataListener: null,
|
onMetadataListener: null,
|
||||||
|
onProgressSyncFailing: null,
|
||||||
|
onProgressSyncSuccess: null,
|
||||||
touchStartY: 0,
|
touchStartY: 0,
|
||||||
touchStartTime: 0,
|
touchStartTime: 0,
|
||||||
touchEndY: 0,
|
touchEndY: 0,
|
||||||
useChapterTrack: false,
|
useChapterTrack: false,
|
||||||
|
lockUi: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
touchTrackStart: false,
|
touchTrackStart: false,
|
||||||
dragPercent: 0
|
dragPercent: 0,
|
||||||
|
syncStatus: 0,
|
||||||
|
showMoreMenuDialog: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -146,17 +172,24 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
menuItems() {
|
menuItems() {
|
||||||
var items = []
|
var items = [
|
||||||
items.push({
|
{
|
||||||
text: 'Chapter Track',
|
text: 'Chapter Track',
|
||||||
value: 'chapter_track',
|
value: 'chapter_track',
|
||||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||||
})
|
},
|
||||||
items.push({
|
{
|
||||||
text: 'Close Player',
|
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
||||||
value: 'close',
|
value: 'lock',
|
||||||
icon: 'close'
|
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
text: 'Close Player',
|
||||||
|
value: 'close',
|
||||||
|
icon: 'close'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
jumpForwardIcon() {
|
jumpForwardIcon() {
|
||||||
@@ -303,7 +336,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
touchstartTrack(e) {
|
touchstartTrack(e) {
|
||||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
|
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||||
this.touchTrackStart = true
|
this.touchTrackStart = true
|
||||||
},
|
},
|
||||||
selectChapter(chapter) {
|
selectChapter(chapter) {
|
||||||
@@ -453,6 +486,10 @@ export default {
|
|||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||||
|
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
if (this.useChapterTrack) {
|
if (this.useChapterTrack) {
|
||||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||||
@@ -480,7 +517,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickTrack(e) {
|
clickTrack(e) {
|
||||||
if (this.isLoading) return
|
if (this.isLoading || this.lockUi) return
|
||||||
if (!this.showFullscreen) {
|
if (!this.showFullscreen) {
|
||||||
// Track not clickable on mini-player
|
// Track not clickable on mini-player
|
||||||
return
|
return
|
||||||
@@ -605,18 +642,22 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickMenuAction(action) {
|
clickMenuAction(action) {
|
||||||
if (action === 'chapter_track') {
|
this.showMoreMenuDialog = false
|
||||||
this.useChapterTrack = !this.useChapterTrack
|
this.$nextTick(() => {
|
||||||
|
if (action === 'lock') {
|
||||||
|
this.lockUi = !this.lockUi
|
||||||
|
this.$localStore.setPlayerLock(this.lockUi)
|
||||||
|
} else if (action === 'chapter_track') {
|
||||||
|
this.useChapterTrack = !this.useChapterTrack
|
||||||
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
})
|
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
} else if (action === 'close') {
|
||||||
} else if (action === 'close') {
|
this.closePlayback()
|
||||||
this.closePlayback()
|
}
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
forceCloseDropdownMenu() {
|
forceCloseDropdownMenu() {
|
||||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||||
@@ -675,6 +716,7 @@ export default {
|
|||||||
|
|
||||||
this.isEnded = false
|
this.isEnded = false
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
this.syncStatus = 0
|
||||||
this.$store.commit('setPlayerItem', this.playbackSession)
|
this.$store.commit('setPlayerItem', this.playbackSession)
|
||||||
|
|
||||||
// Set track width
|
// Set track width
|
||||||
@@ -697,12 +739,15 @@ export default {
|
|||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||||
|
this.lockUi = await this.$localStore.getPlayerLock()
|
||||||
|
|
||||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||||
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
||||||
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
||||||
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||||
|
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||||
|
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||||
},
|
},
|
||||||
screenOrientationChange() {
|
screenOrientationChange() {
|
||||||
setTimeout(this.updateScreenSize, 50)
|
setTimeout(this.updateScreenSize, 50)
|
||||||
@@ -716,13 +761,23 @@ export default {
|
|||||||
minimizePlayerEvt() {
|
minimizePlayerEvt() {
|
||||||
console.log('Minimize Player Evt')
|
console.log('Minimize Player Evt')
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
|
},
|
||||||
|
showProgressSyncIsFailing() {
|
||||||
|
this.syncStatus = this.$constants.SyncStatus.FAILED
|
||||||
|
},
|
||||||
|
showProgressSyncSuccess() {
|
||||||
|
this.syncStatus = this.$constants.SyncStatus.SUCCESS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
if (screen.orientation) {
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||||
}
|
}
|
||||||
|
window.addEventListener('resize', this.screenOrientationChange)
|
||||||
|
|
||||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||||
document.body.addEventListener('touchstart', this.touchstart)
|
document.body.addEventListener('touchstart', this.touchstart)
|
||||||
@@ -732,8 +787,12 @@ export default {
|
|||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (screen.orientation) {
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||||
}
|
}
|
||||||
|
window.removeEventListener('resize', this.screenOrientationChange)
|
||||||
|
|
||||||
if (this.playbackSession) {
|
if (this.playbackSession) {
|
||||||
console.log('[AudioPlayer] Before destroy closing playback')
|
console.log('[AudioPlayer] Before destroy closing playback')
|
||||||
@@ -751,6 +810,8 @@ export default {
|
|||||||
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
|
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
|
||||||
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
|
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
|
||||||
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
||||||
|
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
||||||
|
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
|
||||||
clearInterval(this.playInterval)
|
clearInterval(this.playInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { Dialog } from '@capacitor/dialog'
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
isReady: false,
|
||||||
|
settingsLoaded: false,
|
||||||
audioPlayerReady: false,
|
audioPlayerReady: false,
|
||||||
stream: null,
|
stream: null,
|
||||||
download: null,
|
download: null,
|
||||||
@@ -30,11 +32,10 @@ export default {
|
|||||||
onSleepTimerEndedListener: null,
|
onSleepTimerEndedListener: null,
|
||||||
onSleepTimerSetListener: null,
|
onSleepTimerSetListener: null,
|
||||||
onMediaPlayerChangedListener: null,
|
onMediaPlayerChangedListener: null,
|
||||||
onProgressSyncFailing: null,
|
|
||||||
sleepInterval: null,
|
sleepInterval: null,
|
||||||
currentEndOfChapterTime: 0,
|
currentEndOfChapterTime: 0,
|
||||||
serverLibraryItemId: null,
|
serverLibraryItemId: null,
|
||||||
syncFailedToast: null
|
serverEpisodeId: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -152,6 +153,10 @@ export default {
|
|||||||
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
||||||
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings have been loaded (at least once, so it's safe to kickoff onReady)
|
||||||
|
this.settingsLoaded = true
|
||||||
|
this.notifyOnReady()
|
||||||
},
|
},
|
||||||
setListeners() {
|
setListeners() {
|
||||||
// if (!this.$server.socket) {
|
// if (!this.$server.socket) {
|
||||||
@@ -175,15 +180,15 @@ export default {
|
|||||||
this.$toast.error(`Cannot cast locally downloaded media`)
|
this.$toast.error(`Cannot cast locally downloaded media`)
|
||||||
} else {
|
} else {
|
||||||
// Change to server library item
|
// Change to server library item
|
||||||
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
|
this.playServerLibraryItemAndCast(this.serverLibraryItemId, this.serverEpisodeId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
playServerLibraryItemAndCast(libraryItemId) {
|
playServerLibraryItemAndCast(libraryItemId, episodeId) {
|
||||||
var playbackRate = 1
|
var playbackRate = 1
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
||||||
}
|
}
|
||||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
|
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: false, playbackRate })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
const errorMsg = data.error || 'Failed to play'
|
const errorMsg = data.error || 'Failed to play'
|
||||||
@@ -205,6 +210,7 @@ export default {
|
|||||||
// When playing local library item and can also play this item from the server
|
// When playing local library item and can also play this item from the server
|
||||||
// then store the server library item id so it can be used if a cast is made
|
// then store the server library item id so it can be used if a cast is made
|
||||||
var serverLibraryItemId = payload.serverLibraryItemId || null
|
var serverLibraryItemId = payload.serverLibraryItemId || null
|
||||||
|
var serverEpisodeId = payload.serverEpisodeId || null
|
||||||
|
|
||||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
@@ -217,6 +223,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.serverLibraryItemId = null
|
this.serverLibraryItemId = null
|
||||||
|
this.serverEpisodeId = null
|
||||||
|
|
||||||
var playbackRate = 1
|
var playbackRate = 1
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
@@ -236,6 +243,11 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.serverLibraryItemId = serverLibraryItemId
|
this.serverLibraryItemId = serverLibraryItemId
|
||||||
}
|
}
|
||||||
|
if (episodeId && !episodeId.startsWith('local')) {
|
||||||
|
this.serverEpisodeId = episodeId
|
||||||
|
} else {
|
||||||
|
this.serverEpisodeId = serverEpisodeId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -256,9 +268,17 @@ export default {
|
|||||||
var mediaPlayer = data.value
|
var mediaPlayer = data.value
|
||||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
this.$store.commit('setMediaPlayer', mediaPlayer)
|
||||||
},
|
},
|
||||||
showProgressSyncIsFailing() {
|
onReady() {
|
||||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
// The UI is reporting elsewhere we are ready
|
||||||
this.syncFailedToast = this.$toast('Progress is not being synced', { timeout: false, type: 'error' })
|
this.isReady = true
|
||||||
|
this.notifyOnReady()
|
||||||
|
},
|
||||||
|
notifyOnReady() {
|
||||||
|
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||||
|
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||||
|
if ( this.settingsLoaded && this.isReady ) {
|
||||||
|
AbsAudioPlayer.onReady()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -266,12 +286,12 @@ export default {
|
|||||||
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
||||||
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
||||||
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
||||||
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
|
||||||
|
|
||||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||||
|
|
||||||
this.setListeners()
|
this.setListeners()
|
||||||
|
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||||
@@ -283,7 +303,6 @@ export default {
|
|||||||
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
|
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
|
||||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
||||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
||||||
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
|
||||||
|
|
||||||
// if (this.$server.socket) {
|
// if (this.$server.socket) {
|
||||||
// this.$server.socket.off('stream_open', this.streamOpen)
|
// this.$server.socket.off('stream_open', this.streamOpen)
|
||||||
@@ -292,6 +311,7 @@ export default {
|
|||||||
// this.$server.socket.off('stream_ready', this.streamReady)
|
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||||
// this.$server.socket.off('stream_reset', this.streamReset)
|
// this.$server.socket.off('stream_reset', this.streamReset)
|
||||||
// }
|
// }
|
||||||
|
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export default {
|
|||||||
text: 'Account',
|
text: 'Account',
|
||||||
to: '/account'
|
to: '/account'
|
||||||
})
|
})
|
||||||
|
items.push({
|
||||||
|
icon: 'equalizer',
|
||||||
|
text: 'User Stats',
|
||||||
|
to: '/stats'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.$platform !== 'ios') {
|
if (this.$platform !== 'ios') {
|
||||||
@@ -108,6 +113,13 @@ export default {
|
|||||||
text: 'Local Media',
|
text: 'Local Media',
|
||||||
to: '/localMedia/folders'
|
to: '/localMedia/folders'
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
icon: 'download',
|
||||||
|
iconOutlined: false,
|
||||||
|
text: 'Downloads',
|
||||||
|
to: '/downloads'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" class="w-full max-w-full h-full">
|
<div id="bookshelf" class="w-full max-w-full h-full">
|
||||||
<template v-for="shelf in totalShelves">
|
<template v-for="shelf in totalShelves">
|
||||||
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
|
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView || altViewEnabled ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
|
||||||
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
|
<div v-if="!showBookshelfListView && !altViewEnabled" class="w-full absolute bottom-0 left-0 z-30 bookshelfDivider" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
|
||||||
<div v-else class="flex border-t border-white border-opacity-10" />
|
<div v-else-if="showBookshelfListView" class="flex border-t border-white border-opacity-10" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -119,12 +119,23 @@ export default {
|
|||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
if (this.showBookshelfListView) return this.entityHeight + 16
|
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
|
return this.entityHeight + 40
|
||||||
},
|
},
|
||||||
totalEntityCardWidth() {
|
totalEntityCardWidth() {
|
||||||
if (this.showBookshelfListView) return this.entityWidth
|
if (this.showBookshelfListView) return this.entityWidth
|
||||||
// Includes margin
|
// Includes margin
|
||||||
return this.entityWidth + 24
|
return this.entityWidth + 24
|
||||||
|
},
|
||||||
|
altViewEnabled() {
|
||||||
|
return this.$store.getters['getAltViewEnabled']
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
|
return this.entityWidth / baseSize
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full relative">
|
<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">
|
<template v-for="(entity, index) in entities">
|
||||||
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-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-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-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-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-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" />
|
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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 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 shinyBlack flex items-center justify-center rounded-sm border">
|
<div class="w-full h-full flex items-center justify-center rounded-sm border shinyBlack">
|
||||||
<p class="transform text-xs">{{ label }}</p>
|
<p class="transform text-xs">{{ label }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,23 +36,43 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
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
|
return this.entityHeight + 40
|
||||||
},
|
},
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
var coverSize = 100
|
var coverSize = 100
|
||||||
if (this.bookCoverAspectRatio === 1) return coverSize * 1.6
|
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||||
return coverSize
|
return coverSize
|
||||||
},
|
},
|
||||||
bookHeight() {
|
bookHeight() {
|
||||||
if (this.bookCoverAspectRatio === 1) return this.bookWidth
|
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
||||||
return this.bookWidth * 1.6
|
return this.bookWidth * 1.6
|
||||||
},
|
},
|
||||||
entityHeight() {
|
entityHeight() {
|
||||||
return this.bookHeight
|
return this.bookHeight
|
||||||
},
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
|
return this.bookWidth / baseSize
|
||||||
|
},
|
||||||
|
isCoverSquareAspectRatio() {
|
||||||
|
return this.bookCoverAspectRatio === 1
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
altViewEnabled() {
|
||||||
|
return this.$store.getters['getAltViewEnabled']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- 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' }">
|
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
{{ displayTitle }}
|
{{ displayTitle }}
|
||||||
</p>
|
</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>
|
<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>
|
<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>
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- No progress shown for collapsed series in library -->
|
||||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export default {
|
|||||||
},
|
},
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
showSequence: Boolean,
|
showSequence: Boolean,
|
||||||
bookshelfView: Number,
|
isAltViewEnabled: Boolean,
|
||||||
bookMount: {
|
bookMount: {
|
||||||
// Book can be passed as prop or set with setEntity()
|
// Book can be passed as prop or set with setEntity()
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -164,6 +164,7 @@ export default {
|
|||||||
return this.media.numTracks
|
return this.media.numTracks
|
||||||
},
|
},
|
||||||
numEpisodes() {
|
numEpisodes() {
|
||||||
|
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||||
return this.media.numEpisodes
|
return this.media.numEpisodes
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
@@ -239,7 +240,7 @@ export default {
|
|||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
@@ -250,7 +251,7 @@ export default {
|
|||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||||
},
|
},
|
||||||
userProgress() {
|
userProgress() {
|
||||||
if (this.episodeProgress) return this.episodeProgress
|
if (this.recentEpisode) return this.episodeProgress || null
|
||||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
@@ -346,6 +347,22 @@ export default {
|
|||||||
return this.author.slice(0, 27) + '...'
|
return this.author.slice(0, 27) + '...'
|
||||||
}
|
}
|
||||||
return this.author
|
return this.author
|
||||||
|
},
|
||||||
|
titleDisplayBottomOffset() {
|
||||||
|
if (!this.isAltViewEnabled) return 0
|
||||||
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
|
return 4.25 * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
showHasLocalDownload() {
|
||||||
|
if (this.localLibraryItem || this.isLocal) {
|
||||||
|
if (this.recentEpisode && !this.isLocal) {
|
||||||
|
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
|
return !!localEpisode
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -394,8 +411,26 @@ export default {
|
|||||||
this.selectBtnClick()
|
this.selectBtnClick()
|
||||||
} else if (this.recentEpisode) {
|
} else if (this.recentEpisode) {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
if (this.streamIsPlaying) eventBus.$emit('pause-item')
|
if (this.streamIsPlaying) {
|
||||||
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
eventBus.$emit('pause-item')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.localLibraryItem) {
|
||||||
|
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
|
if (localEpisode) {
|
||||||
|
// Play episode locally
|
||||||
|
eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.localLibraryItemId,
|
||||||
|
episodeId: localEpisode.id,
|
||||||
|
serverLibraryItemId: this.libraryItemId,
|
||||||
|
serverEpisodeId: this.recentEpisode.id
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||||
} else {
|
} else {
|
||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
if (router) {
|
if (router) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</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="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 shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,7 +19,8 @@ export default {
|
|||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
isAltViewEnabled: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||||
{{ displayTitle }}<span v-if="seriesSequence"> #{{ seriesSequence }}</span>
|
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
@@ -109,8 +109,8 @@ export default {
|
|||||||
},
|
},
|
||||||
episodes() {
|
episodes() {
|
||||||
if (this.isPodcast) {
|
if (this.isPodcast) {
|
||||||
if (this.media.numEpisodes==1) {
|
if (this.media.numEpisodes == 1) {
|
||||||
return "1 episode"
|
return '1 episode'
|
||||||
} else {
|
} else {
|
||||||
return this.media.numEpisodes + ' episodes'
|
return this.media.numEpisodes + ' episodes'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
<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>
|
||||||
|
|
||||||
<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 v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,6 +27,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
|
isAltViewEnabled: Boolean,
|
||||||
isCategorized: Boolean
|
isCategorized: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<form v-show="!showAuth" @submit.prevent="submit" novalidate 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>
|
<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 h-10" />
|
<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="flex justify-end items-center mt-6">
|
||||||
@@ -149,7 +152,7 @@ export default {
|
|||||||
var payload = await this.authenticateToken()
|
var payload = await this.authenticateToken()
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
|
this.setUserAndConnection(payload)
|
||||||
} else {
|
} else {
|
||||||
this.showAuth = true
|
this.showAuth = true
|
||||||
}
|
}
|
||||||
@@ -273,7 +276,8 @@ export default {
|
|||||||
this.error = 'Invalid username'
|
this.error = 'Invalid username'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === this.serverConfig.username)
|
|
||||||
|
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === this.serverConfig.username && this.serverConfig.id !== scc.id)
|
||||||
if (duplicateConfig) {
|
if (duplicateConfig) {
|
||||||
this.error = 'Config already exists for this address and username'
|
this.error = 'Config already exists for this address and username'
|
||||||
return
|
return
|
||||||
@@ -285,14 +289,16 @@ export default {
|
|||||||
var payload = await this.requestServerLogin()
|
var payload = await this.requestServerLogin()
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (payload) {
|
if (payload) {
|
||||||
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
|
this.setUserAndConnection(payload)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setUserAndConnection(user, userDefaultLibraryId) {
|
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
console.log('Successfully logged in', JSON.stringify(user))
|
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
|
// Set library - Use last library if set and available fallback to default user library
|
||||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||||
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||||
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||||
<template v-if="page === 'library'">
|
<template v-if="page === 'library'">
|
||||||
<div class="relative flex items-center px-2">
|
<div class="relative flex items-center px-2">
|
||||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||||
<div class="relative flex items-center px-3">
|
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
<div class="relative flex items-center px-3">
|
||||||
</div>
|
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||||
</li>
|
</div>
|
||||||
|
</li>
|
||||||
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ export default {
|
|||||||
value: 'narrators',
|
value: 'narrators',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Language',
|
||||||
|
value: 'languages',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Progress',
|
text: 'Progress',
|
||||||
value: 'progress',
|
value: 'progress',
|
||||||
@@ -165,6 +170,9 @@ export default {
|
|||||||
narrators() {
|
narrators() {
|
||||||
return this.filterData.narrators || []
|
return this.filterData.narrators || []
|
||||||
},
|
},
|
||||||
|
languages() {
|
||||||
|
return this.filterData.languages || []
|
||||||
|
},
|
||||||
progress() {
|
progress() {
|
||||||
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async clickedOption(lib) {
|
async clickedOption(lib) {
|
||||||
this.show = false
|
this.show = false
|
||||||
|
if (lib.id === this.currentLibraryId) return
|
||||||
await this.$store.dispatch('libraries/fetch', lib.id)
|
await this.$store.dispatch('libraries/fetch', lib.id)
|
||||||
this.$eventBus.$emit('library-changed')
|
this.$eventBus.$emit('library-changed', lib.id)
|
||||||
this.$localStore.setLastLibraryId(lib.id)
|
this.$localStore.setLastLibraryId(lib.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export default {
|
|||||||
text: 'Author (Last, First)',
|
text: 'Author (Last, First)',
|
||||||
value: 'media.metadata.authorNameLF'
|
value: 'media.metadata.authorNameLF'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Published Year',
|
||||||
|
value: 'media.metadata.publishedYear'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Added At',
|
text: 'Added At',
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
|
|||||||
@@ -6,9 +6,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div
|
||||||
|
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||||
|
@click="
|
||||||
|
show = false
|
||||||
|
manualTimerModal = false
|
||||||
|
"
|
||||||
|
>
|
||||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
<div 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 v-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<div v-if="manualTimerModal" class="p-4">
|
||||||
|
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||||
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex my-2 justify-between">
|
||||||
|
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||||
|
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||||
|
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||||
|
</div>
|
||||||
|
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="timeout in timeouts">
|
<template v-for="timeout in timeouts">
|
||||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
@@ -21,8 +38,13 @@
|
|||||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal block truncate text-lg text-center">Manual sleep timer</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-else class="px-2 py-4">
|
<div v-else class="p-4">
|
||||||
<div class="flex my-2 justify-between">
|
<div class="flex my-2 justify-between">
|
||||||
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||||
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
||||||
@@ -45,7 +67,10 @@ export default {
|
|||||||
currentEndOfChapterTime: Number
|
currentEndOfChapterTime: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
manualTimerModal: null,
|
||||||
|
manualTimeoutMin: 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
@@ -57,7 +82,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
timeouts() {
|
timeouts() {
|
||||||
return [1, 5, 10, 15, 30, 45, 60, 90]
|
return [5, 10, 15, 30, 45, 60, 90]
|
||||||
},
|
},
|
||||||
timeRemainingPretty() {
|
timeRemainingPretty() {
|
||||||
return this.$secondsToTimestamp(this.currentTime)
|
return this.$secondsToTimestamp(this.currentTime)
|
||||||
@@ -71,6 +96,7 @@ export default {
|
|||||||
clickedOption(timeoutMin) {
|
clickedOption(timeoutMin) {
|
||||||
var timeout = timeoutMin * 1000 * 60
|
var timeout = timeoutMin * 1000 * 60
|
||||||
this.show = false
|
this.show = false
|
||||||
|
this.manualTimerModal = false
|
||||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||||
},
|
},
|
||||||
cancelSleepTimer() {
|
cancelSleepTimer() {
|
||||||
@@ -82,6 +108,12 @@ export default {
|
|||||||
},
|
},
|
||||||
decreaseSleepTime() {
|
decreaseSleepTime() {
|
||||||
this.$emit('decrease')
|
this.$emit('decrease')
|
||||||
|
},
|
||||||
|
increaseManualTimeout() {
|
||||||
|
this.manualTimeoutMin++
|
||||||
|
},
|
||||||
|
decreaseManualTimeout() {
|
||||||
|
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-96 my-6 mx-auto">
|
||||||
|
<h1 class="text-2xl mb-4 font-book">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
|
||||||
|
<div class="relative w-96 h-72">
|
||||||
|
<div class="absolute top-0 left-0">
|
||||||
|
<template v-for="lbl in yAxisLabels">
|
||||||
|
<div :key="lbl" :style="{ height: lineSpacing + 'px' }" class="flex items-center justify-end">
|
||||||
|
<p class="text-xs font-semibold">{{ lbl }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="n in 7">
|
||||||
|
<div :key="n" class="absolute pointer-events-none left-0 h-px bg-white bg-opacity-10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" />
|
||||||
|
|
||||||
|
<div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }">
|
||||||
|
<div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-for="(line, index) in pointLines">
|
||||||
|
<div :key="`line-${index}`" class="absolute h-0.5 bg-yellow-400 origin-bottom-left pointer-events-none" :style="{ width: line.width + 'px', left: line.x + 'px', bottom: line.y + 'px', transform: `rotate(${line.angle}deg)` }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="absolute -bottom-2 left-0 flex ml-6">
|
||||||
|
<template v-for="dayObj in last7Days">
|
||||||
|
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
|
||||||
|
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between pt-12">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Week Listening</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
|
||||||
|
<p class="text-sm text-center">minutes</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Daily Average</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
|
||||||
|
<p class="text-sm text-center">minutes</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Best Day</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
|
||||||
|
<p class="text-sm text-center">minutes</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Days</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
|
||||||
|
<p class="text-sm text-center">in a row</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
listeningStats: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// test: [111, 120, 4, 156, 273, 76, 12],
|
||||||
|
chartHeight: 288,
|
||||||
|
chartWidth: 384,
|
||||||
|
chartContentWidth: 360,
|
||||||
|
chartContentHeight: 268
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
yAxisLabels() {
|
||||||
|
var lbls = []
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
lbls.push(i * this.yAxisFactor)
|
||||||
|
}
|
||||||
|
return lbls
|
||||||
|
},
|
||||||
|
chartContentMarginLeft() {
|
||||||
|
return this.chartWidth - this.chartContentWidth
|
||||||
|
},
|
||||||
|
chartContentMarginBottom() {
|
||||||
|
return this.chartHeight - this.chartContentHeight
|
||||||
|
},
|
||||||
|
lineSpacing() {
|
||||||
|
return this.chartHeight / 7
|
||||||
|
},
|
||||||
|
daySpacing() {
|
||||||
|
return this.chartContentWidth / 7
|
||||||
|
},
|
||||||
|
linePositions() {
|
||||||
|
var poses = []
|
||||||
|
for (let i = 7; i > 0; i--) {
|
||||||
|
poses.push(i * this.lineSpacing)
|
||||||
|
}
|
||||||
|
poses.push(0)
|
||||||
|
return poses
|
||||||
|
},
|
||||||
|
last7Days() {
|
||||||
|
var days = []
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
var _date = this.$addDaysToToday(i * -1)
|
||||||
|
days.push({
|
||||||
|
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
|
||||||
|
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
},
|
||||||
|
last7DaysOfListening() {
|
||||||
|
var listeningDays = {}
|
||||||
|
var _index = 0
|
||||||
|
this.last7Days.forEach((dayObj) => {
|
||||||
|
listeningDays[_index++] = {
|
||||||
|
dayOfWeek: dayObj.dayOfWeek,
|
||||||
|
// minutesListening: this.test[_index - 1]
|
||||||
|
minutesListening: this.getMinutesListeningForDate(dayObj.date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return listeningDays
|
||||||
|
},
|
||||||
|
mostListenedDay() {
|
||||||
|
var sorted = Object.values(this.last7DaysOfListening)
|
||||||
|
.map((dl) => ({ ...dl }))
|
||||||
|
.sort((a, b) => b.minutesListening - a.minutesListening)
|
||||||
|
return sorted[0].minutesListening
|
||||||
|
},
|
||||||
|
yAxisFactor() {
|
||||||
|
var factor = Math.ceil(this.mostListenedDay / 5)
|
||||||
|
|
||||||
|
if (factor > 25) {
|
||||||
|
// Use nearest multiple of 5
|
||||||
|
return Math.ceil(factor / 5) * 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, factor)
|
||||||
|
},
|
||||||
|
points() {
|
||||||
|
var data = []
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
var listeningObj = this.last7DaysOfListening[String(i)]
|
||||||
|
var minutesListening = listeningObj.minutesListening || 0
|
||||||
|
var yPercent = minutesListening / (this.yAxisFactor * 7)
|
||||||
|
data.push({
|
||||||
|
x: 4 + this.chartContentMarginLeft + (this.daySpacing + this.daySpacing / 14) * i,
|
||||||
|
y: this.chartContentMarginBottom + this.chartHeight * yPercent - 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
pointLines() {
|
||||||
|
var lines = []
|
||||||
|
for (let i = 1; i < 7; i++) {
|
||||||
|
var lastPoint = this.points[i - 1]
|
||||||
|
var nextPoint = this.points[i]
|
||||||
|
|
||||||
|
var x1 = lastPoint.x
|
||||||
|
var x2 = nextPoint.x
|
||||||
|
var y1 = lastPoint.y
|
||||||
|
var y2 = nextPoint.y
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
x: x1 + 4,
|
||||||
|
y: y1 + 2,
|
||||||
|
angle: this.getAngleBetweenPoints(x1, y1, x2, y2),
|
||||||
|
width: Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) - 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
},
|
||||||
|
totalMinutesListeningThisWeek() {
|
||||||
|
var _total = 0
|
||||||
|
Object.values(this.last7DaysOfListening).forEach((listeningObj) => (_total += listeningObj.minutesListening))
|
||||||
|
return _total
|
||||||
|
},
|
||||||
|
averageMinutesPerDay() {
|
||||||
|
return Math.round(this.totalMinutesListeningThisWeek / 7)
|
||||||
|
},
|
||||||
|
daysInARow() {
|
||||||
|
var count = 0
|
||||||
|
while (true) {
|
||||||
|
var _date = this.$addDaysToToday(count * -1)
|
||||||
|
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||||
|
|
||||||
|
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
|
||||||
|
if (count > 9999) {
|
||||||
|
console.error('Overflow protection')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
listeningStatsDays() {
|
||||||
|
return this.listeningStats ? this.listeningStats.days || [] : []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getAngleBetweenPoints(cx, cy, ex, ey) {
|
||||||
|
var dy = ey - cy
|
||||||
|
var dx = ex - cx
|
||||||
|
var theta = Math.atan2(dy, dx)
|
||||||
|
theta *= 180 / Math.PI // convert to degrees
|
||||||
|
return theta * -1
|
||||||
|
},
|
||||||
|
getMinutesListeningForDate(date) {
|
||||||
|
if (!this.listeningStats || !this.listeningStats.days) return 0
|
||||||
|
return Math.round((this.listeningStats.days[date] || 0) / 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full bg-primary bg-opacity-40">
|
<div class="w-full bg-primary bg-opacity-40">
|
||||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||||
<p>Collection List</p>
|
<p class="pr-4">Collection List</p>
|
||||||
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
|
|
||||||
<p class="font-mono text-sm">{{ books.length }}</p>
|
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||||
|
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
|
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="book in booksCopy">
|
<template v-for="book in booksCopy">
|
||||||
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
|
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
|
||||||
@@ -39,12 +41,12 @@ export default {
|
|||||||
totalDuration() {
|
totalDuration() {
|
||||||
var _total = 0
|
var _total = 0
|
||||||
this.books.forEach((book) => {
|
this.books.forEach((book) => {
|
||||||
_total += book.duration
|
_total += book.media.duration
|
||||||
})
|
})
|
||||||
return _total
|
return _total
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
return this.$elapsedPretty(this.totalDuration)
|
return this.$elapsedPrettyExtended(this.totalDuration)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||||
<div v-if="book" class="flex w-full">
|
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
|
||||||
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link>
|
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
|
|
||||||
<div v-if="!isIos && userCanDownload">
|
<div v-if="userCanDownload">
|
||||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||||
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||||
@@ -143,7 +143,12 @@ export default {
|
|||||||
},
|
},
|
||||||
downloadClick() {
|
downloadClick() {
|
||||||
if (this.downloadItem) return
|
if (this.downloadItem) return
|
||||||
this.download()
|
if (this.isIos) {
|
||||||
|
// no local folders on iOS
|
||||||
|
this.startDownload()
|
||||||
|
} else {
|
||||||
|
this.download()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
var localFolder = selectedLocalFolder
|
var localFolder = selectedLocalFolder
|
||||||
@@ -183,7 +188,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async startDownload(localFolder) {
|
async startDownload(localFolder) {
|
||||||
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id })
|
var payload = {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: this.episode.id
|
||||||
|
}
|
||||||
|
if (localFolder) {
|
||||||
|
payload.localFolderId = localFolder.id
|
||||||
|
}
|
||||||
|
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
|
||||||
if (downloadRes && downloadRes.error) {
|
if (downloadRes && downloadRes.error) {
|
||||||
var errorMsg = downloadRes.error || 'Unknown error'
|
var errorMsg = downloadRes.error || 'Unknown error'
|
||||||
console.error('Download error', errorMsg)
|
console.error('Download error', errorMsg)
|
||||||
@@ -199,7 +211,9 @@ export default {
|
|||||||
|
|
||||||
this.$eventBus.$emit('play-item', {
|
this.$eventBus.$emit('play-item', {
|
||||||
libraryItemId: this.localLibraryItemId,
|
libraryItemId: this.localLibraryItemId,
|
||||||
episodeId: this.localEpisode.id
|
episodeId: this.localEpisode.id,
|
||||||
|
serverLibraryItemId: this.libraryItemId,
|
||||||
|
serverEpisodeId: this.episode.id
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.$eventBus.$emit('play-item', {
|
this.$eventBus.$emit('play-item', {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default {
|
|||||||
episodesCopy: [],
|
episodesCopy: [],
|
||||||
showFiltersModal: false,
|
showFiltersModal: false,
|
||||||
sortKey: 'publishedAt',
|
sortKey: 'publishedAt',
|
||||||
sortDesc: false,
|
sortDesc: true,
|
||||||
filterKey: 'incomplete',
|
filterKey: 'incomplete',
|
||||||
episodeSortItems: [
|
episodeSortItems: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default {
|
|||||||
networkConnectionType() {
|
networkConnectionType() {
|
||||||
return this.$store.state.networkConnectionType
|
return this.$store.state.networkConnectionType
|
||||||
},
|
},
|
||||||
|
isNetworkUnmetered() {
|
||||||
|
return this.$store.state.isNetworkUnmetered
|
||||||
|
},
|
||||||
isCellular() {
|
isCellular() {
|
||||||
return this.networkConnectionType === 'cellular'
|
return this.networkConnectionType === 'cellular'
|
||||||
},
|
},
|
||||||
@@ -43,6 +46,7 @@ export default {
|
|||||||
iconClass() {
|
iconClass() {
|
||||||
if (!this.networkConnected) return 'text-error'
|
if (!this.networkConnected) return 'text-error'
|
||||||
else if (!this.socketConnected) return 'text-warning'
|
else if (!this.socketConnected) return 'text-warning'
|
||||||
|
else if (!this.isNetworkUnmetered) return 'text-yellow-400'
|
||||||
else if (this.isCellular) return 'text-gray-200'
|
else if (this.isCellular) return 'text-gray-200'
|
||||||
else return 'text-success'
|
else return 'text-success'
|
||||||
}
|
}
|
||||||
@@ -50,14 +54,15 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
showAlertDialog() {
|
showAlertDialog() {
|
||||||
var msg = ''
|
var msg = ''
|
||||||
|
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
|
||||||
if (!this.networkConnected) {
|
if (!this.networkConnected) {
|
||||||
msg = 'No internet'
|
msg = 'No internet'
|
||||||
} else if (!this.socketConnected) {
|
} else if (!this.socketConnected) {
|
||||||
msg = 'Socket not connected'
|
msg = 'Socket not connected'
|
||||||
} else if (this.isCellular) {
|
} else if (this.isCellular) {
|
||||||
msg = 'Socket connected over cellular'
|
msg = `Socket connected over ${meteredString} cellular`
|
||||||
} else {
|
} else {
|
||||||
msg = 'Socket connected over wifi'
|
msg = `Socket connected over ${meteredString} wifi`
|
||||||
}
|
}
|
||||||
Dialog.alert({
|
Dialog.alert({
|
||||||
title: 'Connection Status',
|
title: 'Connection Status',
|
||||||
|
|||||||
@@ -35,7 +35,30 @@
|
|||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; };
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||||
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||||
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||||
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */; };
|
||||||
|
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504D28AC1B0700C746DD /* AudioFile.swift */; };
|
||||||
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504F28AC1B3E00C746DD /* Author.swift */; };
|
||||||
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505128AC1B5D00C746DD /* Chapter.swift */; };
|
||||||
|
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505328AC1B7900C746DD /* AudioTrack.swift */; };
|
||||||
|
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505528AC1BFA00C746DD /* FileMetadata.swift */; };
|
||||||
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505728AC1C1A00C746DD /* Library.swift */; };
|
||||||
|
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505928AC1C4500C746DD /* Folder.swift */; };
|
||||||
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505B28AC1C6200C746DD /* LibraryFile.swift */; };
|
||||||
|
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505D28AC1C8500C746DD /* MediaProgress.swift */; };
|
||||||
|
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */; };
|
||||||
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506128AC1CC900C746DD /* PlayerState.swift */; };
|
||||||
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */; };
|
||||||
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */; };
|
||||||
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506928AC1DF100C746DD /* LocalFile.swift */; };
|
||||||
|
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */; };
|
||||||
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */; };
|
||||||
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; };
|
||||||
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; };
|
||||||
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; };
|
||||||
|
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -71,7 +94,30 @@
|
|||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = "<group>"; };
|
E9D5504528AC1A3900C746DD /* LibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItem.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504728AC1A7A00C746DD /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504928AC1AA600C746DD /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastEpisode.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504D28AC1B0700C746DD /* AudioFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFile.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5504F28AC1B3E00C746DD /* Author.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Author.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505128AC1B5D00C746DD /* Chapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chapter.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505328AC1B7900C746DD /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMetadata.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505728AC1C1A00C746DD /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505928AC1C4500C746DD /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFile.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProgress.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackMetadata.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506128AC1CC900C746DD /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryItem.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPodcastEpisode.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506928AC1DF100C746DD /* LocalFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFile.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMediaProgress.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItem.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = "<group>"; };
|
||||||
|
E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; };
|
||||||
|
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -100,6 +146,7 @@
|
|||||||
children = (
|
children = (
|
||||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
||||||
|
E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
|
||||||
);
|
);
|
||||||
path = player;
|
path = player;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -134,9 +181,14 @@
|
|||||||
children = (
|
children = (
|
||||||
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
||||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
||||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
|
|
||||||
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
||||||
|
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */,
|
||||||
|
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||||
|
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||||
|
E9D5506328AC1D3F00C746DD /* server */,
|
||||||
|
E9D5506428AC1D5800C746DD /* local */,
|
||||||
|
E9D5506D28AC1E7400C746DD /* download */,
|
||||||
);
|
);
|
||||||
path = models;
|
path = models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -150,6 +202,7 @@
|
|||||||
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
||||||
3AB34052280829BF0039308B /* Extensions.swift */,
|
3AB34052280829BF0039308B /* Extensions.swift */,
|
||||||
3AB34054280832720039308B /* PlayerEvents.swift */,
|
3AB34054280832720039308B /* PlayerEvents.swift */,
|
||||||
|
E9D5507228AC218300C746DD /* DaoExtensions.swift */,
|
||||||
);
|
);
|
||||||
path = util;
|
path = util;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -199,6 +252,46 @@
|
|||||||
name = Pods;
|
name = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E9D5506328AC1D3F00C746DD /* server */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5504528AC1A3900C746DD /* LibraryItem.swift */,
|
||||||
|
E9D5504728AC1A7A00C746DD /* MediaType.swift */,
|
||||||
|
E9D5504928AC1AA600C746DD /* Metadata.swift */,
|
||||||
|
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */,
|
||||||
|
E9D5504D28AC1B0700C746DD /* AudioFile.swift */,
|
||||||
|
E9D5504F28AC1B3E00C746DD /* Author.swift */,
|
||||||
|
E9D5505128AC1B5D00C746DD /* Chapter.swift */,
|
||||||
|
E9D5505328AC1B7900C746DD /* AudioTrack.swift */,
|
||||||
|
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */,
|
||||||
|
E9D5505728AC1C1A00C746DD /* Library.swift */,
|
||||||
|
E9D5505928AC1C4500C746DD /* Folder.swift */,
|
||||||
|
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
||||||
|
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
||||||
|
);
|
||||||
|
path = server;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E9D5506428AC1D5800C746DD /* local */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */,
|
||||||
|
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */,
|
||||||
|
E9D5506928AC1DF100C746DD /* LocalFile.swift */,
|
||||||
|
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */,
|
||||||
|
);
|
||||||
|
path = local;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E9D5506D28AC1E7400C746DD /* download */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */,
|
||||||
|
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */,
|
||||||
|
);
|
||||||
|
path = download;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -312,28 +405,51 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
||||||
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||||
|
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||||
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||||
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||||
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
||||||
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
||||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
||||||
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
||||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
||||||
|
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */,
|
||||||
|
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */,
|
||||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||||
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||||
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||||
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||||
|
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||||
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||||
|
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
|
||||||
|
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
|
||||||
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||||
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
||||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
||||||
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
||||||
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
||||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
|
|
||||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
||||||
|
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */,
|
||||||
3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
|
3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
|
||||||
|
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -479,12 +595,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.51;
|
MARKETING_VERSION = 0.9.57;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -503,12 +619,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.51;
|
MARKETING_VERSION = 0.9.57;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
@@ -8,6 +9,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
|
let configuration = Realm.Configuration(
|
||||||
|
schemaVersion: 4,
|
||||||
|
migrationBlock: { migration, oldSchemaVersion in
|
||||||
|
if (oldSchemaVersion < 1) {
|
||||||
|
NSLog("Realm schema version was \(oldSchemaVersion)")
|
||||||
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
|
newObject?["enableAltView"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldSchemaVersion < 4) {
|
||||||
|
NSLog("Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
||||||
|
var indexCounter = 1
|
||||||
|
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||||
|
newObject?["index"] = indexCounter
|
||||||
|
indexCounter += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Realm.Configuration.defaultConfiguration = configuration
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,18 +42,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||||
|
NSLog("Audiobookself is now in the background")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||||
|
NSLog("Audiobookself is now in the foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
|
NSLog("Audiobookself is now active")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
|
NSLog("Audiobookself is terminating")
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
#import <Capacitor/Capacitor.h>
|
#import <Capacitor/Capacitor.h>
|
||||||
|
|
||||||
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
||||||
|
CAP_PLUGIN_METHOD(onReady, CAPPluginReturnNone);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
@objc(AbsAudioPlayer)
|
@objc(AbsAudioPlayer)
|
||||||
public class AbsAudioPlayer: CAPPlugin {
|
public class AbsAudioPlayer: CAPPlugin {
|
||||||
private var initialPlayWhenReady = false
|
private var initialPlayWhenReady = false
|
||||||
private var initialPlaybackRate:Float = 1
|
private var isUIReady = false
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
@@ -21,11 +22,44 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||||
|
|
||||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func onReady(_ call: CAPPluginCall) {
|
||||||
|
Task { await self.restorePlaybackSession() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePlaybackSession() async {
|
||||||
|
// We don't need to restore if we have an active session
|
||||||
|
guard PlayerHandler.getPlaybackSession() == nil else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch the most recent active session
|
||||||
|
let activeSession = try await Realm().objects(PlaybackSession.self).where({
|
||||||
|
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
|
}).last?.freeze()
|
||||||
|
if let activeSession = activeSession {
|
||||||
|
await PlayerProgress.shared.syncFromServer()
|
||||||
|
try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to restore playback session")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func startPlaybackSession(_ session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) throws {
|
||||||
|
guard let libraryItemId = session.libraryItemId else { throw PlayerError.libraryItemIdNotSpecified }
|
||||||
|
|
||||||
|
self.sendPrepareMetadataEvent(itemId: libraryItemId, playWhenReady: playWhenReady)
|
||||||
|
self.sendPlaybackSession(session: try session.asDictionary())
|
||||||
|
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
self.sendMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
||||||
let libraryItemId = call.getString("libraryItemId")
|
let libraryItemId = call.getString("libraryItemId")
|
||||||
let episodeId = call.getString("episodeId")
|
let episodeId = call.getString("episodeId")
|
||||||
@@ -36,32 +70,42 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
NSLog("provide library item id")
|
NSLog("provide library item id")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
if libraryItemId!.starts(with: "local") {
|
|
||||||
NSLog("local items are not implemnted")
|
|
||||||
return call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
initialPlayWhenReady = playWhenReady
|
|
||||||
initialPlaybackRate = playbackRate
|
|
||||||
|
|
||||||
PlayerHandler.stopPlayback()
|
PlayerHandler.stopPlayback()
|
||||||
|
|
||||||
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
|
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
|
||||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
if (isLocalItem) {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||||
|
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||||
|
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
||||||
|
NSLog("Failed to get local playback session")
|
||||||
|
return call.resolve([:])
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
self.sendPlaybackSession(session: try session.asDictionary())
|
try playbackSession.save()
|
||||||
call.resolve(try session.asDictionary())
|
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
call.resolve(try playbackSession.asDictionary())
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to convert session to json")
|
NSLog("Failed to start session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
|
} else { // Playing from the server
|
||||||
|
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
||||||
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
do {
|
||||||
self.sendMetadata()
|
try session.save()
|
||||||
|
try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
call.resolve(try session.asDictionary())
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("Failed to start session")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve([:])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||||
NSLog("Close playback")
|
NSLog("Close playback")
|
||||||
|
|
||||||
@@ -76,7 +120,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.setPlaybackSpeed(speed: call.getFloat("value", 1.0))
|
let playbackRate = call.getFloat("value", 1.0)
|
||||||
|
let settings = PlayerSettings.main()
|
||||||
|
try? settings.update {
|
||||||
|
settings.playbackRate = playbackRate
|
||||||
|
}
|
||||||
|
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +158,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
|
|
||||||
@objc func sendMetadata() {
|
@objc func sendMetadata() {
|
||||||
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
||||||
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
|
if let metadata = PlayerHandler.getMetdata() {
|
||||||
|
self.notifyListeners("onMetadata", data: metadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@objc func sendPlaybackClosedEvent() {
|
@objc func sendPlaybackClosedEvent() {
|
||||||
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
||||||
@@ -117,43 +168,47 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
|
|
||||||
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
|
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
|
||||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||||
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
||||||
|
|
||||||
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000)
|
let seconds = time/1000
|
||||||
|
PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func increaseSleepTime(_ call: CAPPluginCall) {
|
@objc func increaseSleepTime(_ call: CAPPluginCall) {
|
||||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||||
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
||||||
|
|
||||||
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000)
|
let seconds = time/1000
|
||||||
|
PlayerHandler.increaseSleepTime(increaseSeconds: seconds)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSleepTimer(_ call: CAPPluginCall) {
|
@objc func setSleepTimer(_ call: CAPPluginCall) {
|
||||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
||||||
|
let isChapterTime = call.getBool("isChapterTime", false)
|
||||||
|
|
||||||
NSLog("chapter time: \(call.getBool("isChapterTime", false))")
|
let seconds = time / 1000
|
||||||
|
|
||||||
if call.getBool("isChapterTime", false) {
|
NSLog("chapter time: \(isChapterTime)")
|
||||||
let timeToPause = time / 1000 - Int(PlayerHandler.getCurrentTime() ?? 0)
|
if isChapterTime {
|
||||||
if timeToPause < 0 { return call.resolve([ "success": false ]) }
|
PlayerHandler.setChapterSleepTime(stopAt: Double(seconds))
|
||||||
|
|
||||||
NSLog("oof \(timeToPause)")
|
|
||||||
|
|
||||||
PlayerHandler.remainingSleepTime = timeToPause
|
|
||||||
return call.resolve([ "success": true ])
|
return call.resolve([ "success": true ])
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerHandler.remainingSleepTime = time / 1000
|
PlayerHandler.setSleepTime(secondsUntilSleep: Double(seconds))
|
||||||
call.resolve([ "success": true ])
|
call.resolve([ "success": true ])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.remainingSleepTime = nil
|
PlayerHandler.cancelSleepTime()
|
||||||
|
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
||||||
call.resolve([
|
call.resolve([
|
||||||
"value": PlayerHandler.remainingSleepTime
|
"value": PlayerHandler.remainingSleepTime
|
||||||
@@ -165,31 +220,39 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
"value": PlayerHandler.getCurrentTime()
|
"value": PlayerHandler.getCurrentTime()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func sendSleepTimerSet() {
|
@objc func sendSleepTimerSet() {
|
||||||
self.notifyListeners("onSleepTimerSet", data: [
|
self.notifyListeners("onSleepTimerSet", data: [
|
||||||
"value": PlayerHandler.remainingSleepTime
|
"value": PlayerHandler.remainingSleepTime
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func onLocalMediaProgressUpdate() {
|
||||||
|
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||||
|
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
||||||
|
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
||||||
|
NSLog("Sending local progress back to the UI")
|
||||||
|
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func onPlaybackFailed() {
|
@objc func onPlaybackFailed() {
|
||||||
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
||||||
let playbackSession = PlayerHandler.getPlaybackSession()
|
let session = PlayerHandler.getPlaybackSession()
|
||||||
let libraryItemId = playbackSession?.libraryItemId ?? ""
|
let libraryItemId = session?.libraryItemId ?? ""
|
||||||
let episodeId = playbackSession?.episodeId ?? nil
|
let episodeId = session?.episodeId ?? nil
|
||||||
NSLog("Forcing Transcode")
|
NSLog("Forcing Transcode")
|
||||||
|
|
||||||
// If direct playing then fallback to transcode
|
// If direct playing then fallback to transcode
|
||||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
|
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
|
||||||
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
try session.save()
|
||||||
|
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate)
|
||||||
self.sendPlaybackSession(session: try session.asDictionary())
|
self.sendPlaybackSession(session: try session.asDictionary())
|
||||||
|
self.sendMetadata()
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to convert session to json")
|
NSLog("Failed to start transcoded session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sendMetadata()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.notifyListeners("onPlaybackFailed", data: [
|
self.notifyListeners("onPlaybackFailed", data: [
|
||||||
@@ -205,7 +268,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
"playWhenReady": playWhenReady,
|
"playWhenReady": playWhenReady,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||||
self.notifyListeners("onPlaybackSession", data: session)
|
self.notifyListeners("onPlaybackSession", data: session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PlayerError: String, Error {
|
||||||
|
case libraryItemIdNotSpecified = "No libraryItemId provided on session"
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
|
|||||||
CAP_PLUGIN_METHOD(getLocalLibraryItem, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getLocalLibraryItem, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
|
||||||
@@ -35,13 +36,14 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
let token = call.getString("token", "")
|
let token = call.getString("token", "")
|
||||||
|
|
||||||
let name = "\(address) (\(username))"
|
let name = "\(address) (\(username))"
|
||||||
let config = ServerConnectionConfig()
|
|
||||||
|
|
||||||
if id == nil {
|
if id == nil {
|
||||||
id = "\(address)@\(username)".toBase64()
|
id = "\(address)@\(username)".toBase64()
|
||||||
}
|
}
|
||||||
|
|
||||||
config.id = id!
|
let config = ServerConnectionConfig()
|
||||||
|
config.id = id ?? ""
|
||||||
|
config.index = 0
|
||||||
config.name = name
|
config.name = name
|
||||||
config.address = address
|
config.address = address
|
||||||
config.userId = userId
|
config.userId = userId
|
||||||
@@ -49,7 +51,8 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
config.token = token
|
config.token = token
|
||||||
|
|
||||||
Store.serverConfig = config
|
Store.serverConfig = config
|
||||||
call.resolve(convertServerConnectionConfigToJSON(config: config))
|
let savedConfig = Store.serverConfig // Fetch the latest value
|
||||||
|
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
||||||
}
|
}
|
||||||
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
let id = call.getString("serverConnectionConfigId", "")
|
let id = call.getString("serverConnectionConfigId", "")
|
||||||
@@ -75,23 +78,165 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
do {
|
||||||
|
let items = Database.shared.getLocalLibraryItems()
|
||||||
|
call.resolve([ "value": try items.asDictionaryArray()])
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
call.resolve()
|
do {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
||||||
|
switch item {
|
||||||
|
case .some(let foundItem):
|
||||||
|
call.resolve(try foundItem.asDictionary())
|
||||||
|
default:
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
||||||
call.resolve()
|
do {
|
||||||
|
let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
|
||||||
|
switch item {
|
||||||
|
case .some(let foundItem):
|
||||||
|
call.resolve(try foundItem.asDictionary())
|
||||||
|
default:
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
} catch(let exception) {
|
||||||
|
NSLog("error while readling local library items")
|
||||||
|
debugPrint(exception)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
||||||
call.resolve([ "value": [] ])
|
call.resolve([ "value": [] ])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
||||||
|
do {
|
||||||
|
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||||
|
} catch {
|
||||||
|
NSLog("Error while loading local media progress")
|
||||||
|
debugPrint(error)
|
||||||
|
call.resolve(["value": []])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func removeLocalMediaProgress(_ call: CAPPluginCall) {
|
||||||
|
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||||
|
guard let localMediaProgressId = localMediaProgressId else {
|
||||||
|
call.reject("localMediaProgressId not specificed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
|
||||||
|
guard Store.serverConfig != nil else {
|
||||||
|
call.reject("syncLocalMediaProgressWithServer not connected to server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ApiClient.syncMediaProgress { results in
|
||||||
|
do {
|
||||||
|
call.resolve(try results.asDictionary())
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to report synced media progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) {
|
||||||
|
let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self)
|
||||||
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
let localEpisodeId = call.getString("localEpisodeId")
|
||||||
|
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||||
|
|
||||||
|
do {
|
||||||
|
guard let serverMediaProgress = serverMediaProgress else {
|
||||||
|
return call.reject("serverMediaProgress not specified")
|
||||||
|
}
|
||||||
|
guard localLibraryItemId != nil || localMediaProgressId != nil else {
|
||||||
|
return call.reject("localLibraryItemId or localMediaProgressId must be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||||
|
guard let localMediaProgress = localMediaProgress else {
|
||||||
|
call.reject("Local media progress not found or created")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
|
||||||
|
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
||||||
|
|
||||||
|
call.resolve(try localMediaProgress.asDictionary())
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to sync media progress")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
|
||||||
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
let localEpisodeId = call.getString("localEpisodeId")
|
||||||
|
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||||
|
let isFinished = call.getBool("isFinished", false)
|
||||||
|
|
||||||
|
NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||||
|
guard let localMediaProgress = localMediaProgress else {
|
||||||
|
call.resolve(["error": "Library Item not found"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update finished status
|
||||||
|
try localMediaProgress.updateIsFinished(isFinished)
|
||||||
|
|
||||||
|
// Build API response
|
||||||
|
let progressDictionary = try? localMediaProgress.asDictionary()
|
||||||
|
var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
|
||||||
|
|
||||||
|
// Send update to the server if logged in
|
||||||
|
let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil
|
||||||
|
let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId
|
||||||
|
if hasLinkedServer && loggedIntoServer {
|
||||||
|
response["server"] = true
|
||||||
|
let payload = ["isFinished": isFinished]
|
||||||
|
ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) {
|
||||||
|
call.resolve(response)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
call.resolve(response)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
call.resolve(["error": "Failed to mark as complete"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
||||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||||
|
let enableAltView = call.getBool("enableAltView") ?? false
|
||||||
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
||||||
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
||||||
let settings = DeviceSettings()
|
let settings = DeviceSettings()
|
||||||
settings.disableAutoRewind = disableAutoRewind
|
settings.disableAutoRewind = disableAutoRewind
|
||||||
|
settings.enableAltView = enableAltView
|
||||||
settings.jumpBackwardsTime = jumpBackwardsTime
|
settings.jumpBackwardsTime = jumpBackwardsTime
|
||||||
settings.jumpForwardTime = jumpForwardTime
|
settings.jumpForwardTime = jumpForwardTime
|
||||||
|
|
||||||
|
|||||||
@@ -7,65 +7,382 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
@objc(AbsDownloader)
|
@objc(AbsDownloader)
|
||||||
public class AbsDownloader: CAPPlugin {
|
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||||
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
|
||||||
let libraryItemId = call.getString("libraryItemId")
|
|
||||||
let episodeId = call.getString("episodeId")
|
|
||||||
|
|
||||||
NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")")
|
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
|
||||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in
|
private lazy var session: URLSession = {
|
||||||
if (libraryItem == nil) {
|
let queue = OperationQueue()
|
||||||
NSLog("Library item not found")
|
queue.maxConcurrentOperationCount = 5
|
||||||
call.resolve()
|
return URLSession(configuration: .default, delegate: self, delegateQueue: queue)
|
||||||
} else {
|
}()
|
||||||
NSLog("Got library item \(libraryItem!)")
|
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||||
|
private var downloadItemProgress = [String: DownloadItem]()
|
||||||
// TODO: break out in seperate functions
|
private var monitoringProgressTimer: Timer?
|
||||||
libraryItem!.media.tracks?.forEach { track in
|
|
||||||
NSLog("TRACK \(track.contentUrl!)")
|
|
||||||
// filename needs to be encoded otherwise would just use contentUrl
|
|
||||||
let filename = track.metadata?.filename ?? ""
|
|
||||||
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
|
||||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
|
||||||
let url = URL(string: urlstr)!
|
|
||||||
|
|
||||||
|
|
||||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
// MARK: - Progress handling
|
||||||
let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItemId!)")
|
|
||||||
NSLog("ITEM DIR \(itemDirectory)")
|
|
||||||
|
|
||||||
// Create library item directory
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
do {
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false)
|
let realm = try Realm()
|
||||||
} catch {
|
try realm.write {
|
||||||
NSLog("Failed to CREATE LI DIRECTORY \(error)")
|
downloadItemPart.progress = 100
|
||||||
}
|
downloadItemPart.completed = true
|
||||||
|
}
|
||||||
|
|
||||||
// Output filename
|
do {
|
||||||
let trackFilename = itemDirectory.appendingPathComponent("\(filename)")
|
// Move the downloaded file into place
|
||||||
|
guard let destinationUrl = downloadItemPart.destinationURL else {
|
||||||
let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in
|
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||||
|
|
||||||
guard let fileURL = urlOrNil else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
NSLog("Download TMP file URL \(fileURL)")
|
|
||||||
let imageData = try Data(contentsOf:fileURL)
|
|
||||||
try imageData.write(to: trackFilename)
|
|
||||||
NSLog("Download written to \(trackFilename)")
|
|
||||||
} catch {
|
|
||||||
NSLog("FILE ERROR: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadTask.resume()
|
|
||||||
}
|
}
|
||||||
|
try? FileManager.default.removeItem(at: destinationUrl)
|
||||||
|
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||||
call.resolve()
|
try realm.write {
|
||||||
|
downloadItemPart.moved = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try realm.write {
|
||||||
|
downloadItemPart.failed = true
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||||
|
if let error = error {
|
||||||
|
try Realm().write {
|
||||||
|
downloadItemPart.completed = true
|
||||||
|
downloadItemPart.failed = true
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
|
// Calculate the download percentage
|
||||||
|
let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100
|
||||||
|
// Only update the progress if we received accurate progress data
|
||||||
|
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||||
|
try Realm().write {
|
||||||
|
downloadItemPart.progress = percentDownloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||||
|
do {
|
||||||
|
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
||||||
|
NSLog("Received download update for \(downloadItemPartId)")
|
||||||
|
|
||||||
|
// Find the download item
|
||||||
|
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
||||||
|
guard var downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound }
|
||||||
|
|
||||||
|
// Find the download item part
|
||||||
|
let part = downloadItem.downloadItemParts.first(where: { $0.id == downloadItemPartId })
|
||||||
|
guard let part = part else { throw LibraryItemDownloadError.downloadItemPartNotFound }
|
||||||
|
|
||||||
|
// Call the progress handler
|
||||||
|
do {
|
||||||
|
try progressHandler(downloadItem, part)
|
||||||
|
} catch {
|
||||||
|
NSLog("Error while processing progress")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
downloadItem = downloadItem.freeze()
|
||||||
|
self.progressStatusQueue.async(flags: .barrier) {
|
||||||
|
self.downloadItemProgress.updateValue(downloadItem, forKey: downloadItem.id!)
|
||||||
|
}
|
||||||
|
self.notifyDownloadProgress()
|
||||||
|
} catch {
|
||||||
|
NSLog("DownloadItemError")
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
|
||||||
|
private func notifyDownloadProgress() {
|
||||||
|
if self.monitoringProgressTimer?.isValid ?? false {
|
||||||
|
NSLog("Already monitoring progress, no need to start timer again")
|
||||||
|
} else {
|
||||||
|
DispatchQueue.runOnMainQueue {
|
||||||
|
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in
|
||||||
|
NSLog("Starting monitoring download progress...")
|
||||||
|
|
||||||
|
// Fetch active downloads in a thread-safe way
|
||||||
|
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
||||||
|
self.progressStatusQueue.sync {
|
||||||
|
let activeDownloads = self.downloadItemProgress
|
||||||
|
if activeDownloads.isEmpty {
|
||||||
|
NSLog("Finishing monitoring download progress...")
|
||||||
|
t.invalidate()
|
||||||
|
}
|
||||||
|
return activeDownloads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a completed download item in a thread-safe way
|
||||||
|
func handleDoneDownloadItem(_ item: DownloadItem) {
|
||||||
|
self.progressStatusQueue.async(flags: .barrier) {
|
||||||
|
self.downloadItemProgress.removeValue(forKey: item.id!)
|
||||||
|
}
|
||||||
|
self.handleDownloadTaskCompleteFromDownloadItem(item)
|
||||||
|
if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) {
|
||||||
|
try? item.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit status for active downloads
|
||||||
|
if let activeDownloads = fetchActiveDownloads() {
|
||||||
|
for item in activeDownloads.values {
|
||||||
|
try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary())
|
||||||
|
if item.isDoneDownloading() { handleDoneDownloadItem(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) {
|
||||||
|
var statusNotification = [String: Any]()
|
||||||
|
statusNotification["libraryItemId"] = downloadItem.id
|
||||||
|
|
||||||
|
if ( downloadItem.didDownloadSuccessfully() ) {
|
||||||
|
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in
|
||||||
|
guard let libraryItem = libraryItem else { NSLog("LibraryItem not found"); return }
|
||||||
|
let localDirectory = libraryItem.id
|
||||||
|
var coverFile: String?
|
||||||
|
|
||||||
|
// Assemble the local library item
|
||||||
|
let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in
|
||||||
|
var mimeType = part.mimeType()
|
||||||
|
if part.filename == "cover.jpg" {
|
||||||
|
coverFile = part.destinationUri
|
||||||
|
mimeType = "image/jpg"
|
||||||
|
}
|
||||||
|
return LocalFile(libraryItem.id, part.filename!, mimeType!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize))
|
||||||
|
}
|
||||||
|
var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: libraryItem.id)
|
||||||
|
if (localLibraryItem != nil && localLibraryItem!.isPodcast) {
|
||||||
|
try? Realm().write {
|
||||||
|
try? localLibraryItem?.addFiles(files, item: libraryItem)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile)
|
||||||
|
try? Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary()
|
||||||
|
|
||||||
|
if let progress = libraryItem.userMediaProgress {
|
||||||
|
let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId })
|
||||||
|
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress)
|
||||||
|
try? localMediaProgress.save()
|
||||||
|
statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Capacitor functions
|
||||||
|
|
||||||
|
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
||||||
|
let libraryItemId = call.getString("libraryItemId")
|
||||||
|
var episodeId = call.getString("episodeId")
|
||||||
|
if ( episodeId == "null" ) { episodeId = nil }
|
||||||
|
|
||||||
|
NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
|
||||||
|
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
|
||||||
|
|
||||||
|
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in
|
||||||
|
if let libraryItem = libraryItem {
|
||||||
|
NSLog("Got library item from server \(libraryItem.id)")
|
||||||
|
do {
|
||||||
|
if let episodeId = episodeId {
|
||||||
|
// Download a podcast episode
|
||||||
|
guard libraryItem.mediaType == "podcast" else { throw LibraryItemDownloadError.libraryItemNotPodcast }
|
||||||
|
let episode = libraryItem.media?.episodes.enumerated().first(where: { $1.id == episodeId })?.element
|
||||||
|
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
|
||||||
|
try self.startLibraryItemDownload(libraryItem, episode: episode)
|
||||||
|
} else {
|
||||||
|
// Download a book
|
||||||
|
try self.startLibraryItemDownload(libraryItem)
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
call.resolve(["error": "Failed to download"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
call.resolve(["error": "Server request failed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemDownload(_ item: LibraryItem) throws {
|
||||||
|
try startLibraryItemDownload(item, episode: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemDownload(_ item: LibraryItem, episode: PodcastEpisode?) throws {
|
||||||
|
let tracks = List<AudioTrack>()
|
||||||
|
var episodeId: String?
|
||||||
|
|
||||||
|
// Handle the different media type downloads
|
||||||
|
switch item.mediaType {
|
||||||
|
case "book":
|
||||||
|
guard item.media?.tracks.count ?? 0 > 0 else { throw LibraryItemDownloadError.noTracks }
|
||||||
|
item.media?.tracks.forEach { t in tracks.append(AudioTrack.detachCopy(of: t)!) }
|
||||||
|
case "podcast":
|
||||||
|
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
|
||||||
|
guard let podcastTrack = episode.audioTrack else { throw LibraryItemDownloadError.noTracks }
|
||||||
|
episodeId = episode.id
|
||||||
|
tracks.append(AudioTrack.detachCopy(of: podcastTrack)!)
|
||||||
|
default:
|
||||||
|
throw LibraryItemDownloadError.unknownMediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue up everything for downloading
|
||||||
|
let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!)
|
||||||
|
var tasks = [DownloadItemPartTask]()
|
||||||
|
for (i, track) in tracks.enumerated() {
|
||||||
|
let task = try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode)
|
||||||
|
downloadItem.downloadItemParts.append(task.part)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also download the cover
|
||||||
|
if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) {
|
||||||
|
if let task = try? startLibraryItemCoverDownload(item: item) {
|
||||||
|
downloadItem.downloadItemParts.append(task.part)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist in the database before status start coming in
|
||||||
|
try Database.shared.saveDownloadItem(downloadItem)
|
||||||
|
|
||||||
|
// Start all the downloads
|
||||||
|
for task in tasks {
|
||||||
|
task.task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||||
|
NSLog("TRACK \(track.contentUrl!)")
|
||||||
|
|
||||||
|
// If we don't name metadata, then we can't proceed
|
||||||
|
guard let filename = track.metadata?.filename else {
|
||||||
|
throw LibraryItemDownloadError.noMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverUrl = urlForTrack(item: item, track: track)
|
||||||
|
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||||
|
let localUrl = "\(itemDirectory)/\(filename)"
|
||||||
|
|
||||||
|
let task = session.downloadTask(with: serverUrl)
|
||||||
|
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode)
|
||||||
|
|
||||||
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
|
task.taskDescription = part.id
|
||||||
|
|
||||||
|
return DownloadItemPartTask(part: part, task: task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLibraryItemCoverDownload(item: LibraryItem) throws -> DownloadItemPartTask {
|
||||||
|
let filename = "cover.jpg"
|
||||||
|
let serverPath = "/api/items/\(item.id)/cover"
|
||||||
|
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||||
|
let localUrl = "\(itemDirectory)/\(filename)"
|
||||||
|
|
||||||
|
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil)
|
||||||
|
let task = session.downloadTask(with: part.downloadURL!)
|
||||||
|
|
||||||
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
|
task.taskDescription = part.id
|
||||||
|
|
||||||
|
return DownloadItemPartTask(part: part, task: task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
||||||
|
// filename needs to be encoded otherwise would just use contentUrl
|
||||||
|
let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||||
|
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||||
|
return URL(string: urlstr)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
||||||
|
let itemDirectory = item.id
|
||||||
|
NSLog("ITEM DIR \(itemDirectory)")
|
||||||
|
|
||||||
|
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
||||||
|
NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)")
|
||||||
|
throw LibraryItemDownloadError.failedDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
static func itemDownloadFolder(path: String) -> URL? {
|
||||||
|
do {
|
||||||
|
var itemFolder = AbsDownloader.downloadsDirectory.appendingPathComponent(path)
|
||||||
|
|
||||||
|
if !FileManager.default.fileExists(atPath: itemFolder.path) {
|
||||||
|
try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we don't backup download files to iCloud
|
||||||
|
var resourceValues = URLResourceValues()
|
||||||
|
resourceValues.isExcludedFromBackup = true
|
||||||
|
try itemFolder.setResourceValues(resourceValues)
|
||||||
|
|
||||||
|
return itemFolder
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to CREATE LI DIRECTORY \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Class structs
|
||||||
|
|
||||||
|
typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: DownloadItemPart) throws -> Void
|
||||||
|
|
||||||
|
struct DownloadItemPartTask {
|
||||||
|
let part: DownloadItemPart
|
||||||
|
let task: URLSessionDownloadTask
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LibraryItemDownloadError: String, Error {
|
||||||
|
case noTracks = "No tracks on library item"
|
||||||
|
case noMetadata = "No metadata for track, unable to download"
|
||||||
|
case libraryItemNotPodcast = "Library item is not a podcast but episode was requested"
|
||||||
|
case podcastEpisodeNotFound = "Invalid podcast episode not found"
|
||||||
|
case podcastOnlySupported = "Only podcasts are supported for this function"
|
||||||
|
case unknownMediaType = "Unknown media type"
|
||||||
|
case failedDirectory = "Failed to create directory"
|
||||||
|
case failedDownload = "Failed to download item"
|
||||||
|
case noTaskDescription = "No task description"
|
||||||
|
case downloadItemNotFound = "DownloadItem not found"
|
||||||
|
case downloadItemPartNotFound = "DownloadItemPart not found"
|
||||||
|
case downloadItemPartDestinationUrlNotDefined = "DownloadItemPart destination URL not defined"
|
||||||
|
case libraryItemNotFound = "LibraryItem not found for id"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,79 +13,110 @@ public class AbsFileSystem: CAPPlugin {
|
|||||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||||
let mediaType = call.getString("mediaType")
|
let mediaType = call.getString("mediaType")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
|
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||||
let folderUrl = call.getString("folderUrl")
|
let folderUrl = call.getString("folderUrl")
|
||||||
|
|
||||||
// TODO: Is this even necessary on iOS?
|
|
||||||
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve([
|
call.unavailable("Not available on iOS")
|
||||||
"value": true
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func scanFolder(_ call: CAPPluginCall) {
|
@objc func scanFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("removeFolder \(folderId ?? "UNSET")")
|
NSLog("removeFolder \(folderId ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.resolve()
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func deleteItem(_ call: CAPPluginCall) {
|
@objc func deleteItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("id")
|
||||||
let contentUrl = call.getString("contentUrl")
|
let contentUrl = call.getString("contentUrl")
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||||
|
|
||||||
call.resolve()
|
var success = false
|
||||||
|
do {
|
||||||
|
if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||||
|
try FileManager.default.removeItem(at: item.contentDirectory!)
|
||||||
|
try item.delete()
|
||||||
|
success = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to delete \(error)")
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
|
||||||
|
call.resolve(["success": success])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
|
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("id")
|
||||||
let trackLocalFileId = call.getString("trackLocalFileId")
|
let trackLocalFileId = call.getString("trackLocalFileId")
|
||||||
let contentUrl = call.getString("contentUrl")
|
|
||||||
|
|
||||||
// TODO: Implement
|
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
||||||
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
|
||||||
|
|
||||||
call.resolve()
|
var success = false
|
||||||
|
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||||
|
do {
|
||||||
|
try item.update {
|
||||||
|
do {
|
||||||
|
if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) {
|
||||||
|
try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath)
|
||||||
|
item.realm?.delete(item.localFiles[fileIndex])
|
||||||
|
if item.isPodcast, let media = item.media {
|
||||||
|
if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) {
|
||||||
|
media.episodes.remove(at: episodeIndex)
|
||||||
|
}
|
||||||
|
item.media = media
|
||||||
|
}
|
||||||
|
call.resolve(try item.asDictionary())
|
||||||
|
success = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to delete \(error)")
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("Failed to delete \(error)")
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
call.resolve(["success": success])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ install! 'cocoapods', :disable_input_output_paths => true
|
|||||||
def capacitor_pods
|
def capacitor_pods
|
||||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorApp', :path => '..\..\node_modules\@capacitor\app'
|
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||||
pod 'CapacitorDialog', :path => '..\..\node_modules\@capacitor\dialog'
|
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
||||||
pod 'CapacitorHaptics', :path => '..\..\node_modules\@capacitor\haptics'
|
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
||||||
pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network'
|
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
|
||||||
pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar'
|
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||||
pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage'
|
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'App' do
|
target 'App' do
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
//
|
|
||||||
// DataClasses.swift
|
|
||||||
// App
|
|
||||||
//
|
|
||||||
// Created by Benonymity on 4/20/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreMedia
|
|
||||||
|
|
||||||
struct LibraryItem: Codable {
|
|
||||||
var id: String
|
|
||||||
var ino:String
|
|
||||||
var libraryId: String
|
|
||||||
var folderId: String
|
|
||||||
var path: String
|
|
||||||
var relPath: String
|
|
||||||
var isFile: Bool
|
|
||||||
var mtimeMs: Int64
|
|
||||||
var ctimeMs: Int64
|
|
||||||
var birthtimeMs: Int64
|
|
||||||
var addedAt: Int64
|
|
||||||
var updatedAt: Int64
|
|
||||||
var lastScan: Int64?
|
|
||||||
var scanVersion: String?
|
|
||||||
var isMissing: Bool
|
|
||||||
var isInvalid: Bool
|
|
||||||
var mediaType: String
|
|
||||||
var media: MediaType
|
|
||||||
var libraryFiles: [LibraryFile]
|
|
||||||
var userMediaProgress:MediaProgress?
|
|
||||||
}
|
|
||||||
struct MediaType: Codable {
|
|
||||||
var libraryItemId: String?
|
|
||||||
var metadata: Metadata
|
|
||||||
var coverPath: String?
|
|
||||||
var tags: [String]?
|
|
||||||
var audioFiles: [AudioTrack]?
|
|
||||||
var chapters: [Chapter]?
|
|
||||||
var tracks: [AudioTrack]?
|
|
||||||
var size: Int64?
|
|
||||||
var duration: Double?
|
|
||||||
var episodes: [PodcastEpisode]?
|
|
||||||
var autoDownloadEpisodes: Bool?
|
|
||||||
}
|
|
||||||
struct Metadata: Codable {
|
|
||||||
var title: String
|
|
||||||
var subtitle: String?
|
|
||||||
var authors: [Author]?
|
|
||||||
var narrators: [String]?
|
|
||||||
var genres: [String]
|
|
||||||
var publishedYear: String?
|
|
||||||
var publishedDate: String?
|
|
||||||
var publisher: String?
|
|
||||||
var description: String?
|
|
||||||
var isbn: String?
|
|
||||||
var asin: String?
|
|
||||||
var language: String?
|
|
||||||
var explicit: Bool
|
|
||||||
var authorName: String?
|
|
||||||
var authorNameLF: String?
|
|
||||||
var narratorName: String?
|
|
||||||
var seriesName: String?
|
|
||||||
var feedUrl: String?
|
|
||||||
}
|
|
||||||
struct PodcastEpisode: Codable {
|
|
||||||
var id: String
|
|
||||||
var index: Int
|
|
||||||
var episode: String?
|
|
||||||
var episodeType: String?
|
|
||||||
var title: String
|
|
||||||
var subtitle: String?
|
|
||||||
var description: String?
|
|
||||||
var audioFile: AudioFile?
|
|
||||||
var audioTrack: AudioTrack?
|
|
||||||
var duration: Double
|
|
||||||
var size: Int64
|
|
||||||
// var serverEpisodeId: String?
|
|
||||||
}
|
|
||||||
struct AudioFile: Codable {
|
|
||||||
var index: Int
|
|
||||||
var ino: String
|
|
||||||
var metadata: FileMetadata
|
|
||||||
}
|
|
||||||
struct Author: Codable {
|
|
||||||
var id: String
|
|
||||||
var name: String
|
|
||||||
var coverPath: String?
|
|
||||||
}
|
|
||||||
struct Chapter: Codable {
|
|
||||||
var id: Int
|
|
||||||
var start: Double
|
|
||||||
var end: Double
|
|
||||||
var title: String?
|
|
||||||
}
|
|
||||||
struct AudioTrack: Codable {
|
|
||||||
var index: Int?
|
|
||||||
var startOffset: Double?
|
|
||||||
var duration: Double
|
|
||||||
var title: String?
|
|
||||||
var contentUrl: String?
|
|
||||||
var mimeType: String
|
|
||||||
var metadata: FileMetadata?
|
|
||||||
// var isLocal: Bool
|
|
||||||
// var localFileId: String?
|
|
||||||
// var audioProbeResult: AudioProbeResult? Needed for local playback
|
|
||||||
var serverIndex: Int?
|
|
||||||
}
|
|
||||||
struct FileMetadata: Codable {
|
|
||||||
var filename: String
|
|
||||||
var ext: String
|
|
||||||
var path: String
|
|
||||||
var relPath: String
|
|
||||||
}
|
|
||||||
struct Library: Codable {
|
|
||||||
var id: String
|
|
||||||
var name: String
|
|
||||||
var folders: [Folder]
|
|
||||||
var icon: String
|
|
||||||
var mediaType: String
|
|
||||||
}
|
|
||||||
struct Folder: Codable {
|
|
||||||
var id: String
|
|
||||||
var fullPath: String
|
|
||||||
}
|
|
||||||
struct LibraryFile: Codable {
|
|
||||||
var ino: String
|
|
||||||
var metadata: FileMetadata
|
|
||||||
}
|
|
||||||
struct MediaProgress:Codable {
|
|
||||||
var id:String
|
|
||||||
var libraryItemId:String
|
|
||||||
var episodeId:String?
|
|
||||||
var duration:Double
|
|
||||||
var progress:Double
|
|
||||||
var currentTime:Double
|
|
||||||
var isFinished:Bool
|
|
||||||
var lastUpdate:Int64
|
|
||||||
var startedAt:Int64
|
|
||||||
var finishedAt:Int64?
|
|
||||||
}
|
|
||||||
@@ -9,25 +9,21 @@ import Foundation
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class DeviceSettings: Object {
|
class DeviceSettings: Object {
|
||||||
@Persisted var disableAutoRewind: Bool
|
@Persisted var disableAutoRewind: Bool = false
|
||||||
@Persisted var jumpBackwardsTime: Int
|
@Persisted var enableAltView: Bool = false
|
||||||
@Persisted var jumpForwardTime: Int
|
@Persisted var jumpBackwardsTime: Int = 10
|
||||||
|
@Persisted var jumpForwardTime: Int = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||||
let settings = DeviceSettings()
|
return DeviceSettings()
|
||||||
settings.disableAutoRewind = false
|
|
||||||
settings.jumpForwardTime = 10
|
|
||||||
settings.jumpBackwardsTime = 10
|
|
||||||
return settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||||
return Database.realmQueue.sync {
|
return [
|
||||||
return [
|
"disableAutoRewind": settings.disableAutoRewind,
|
||||||
"disableAutoRewind": settings.disableAutoRewind,
|
"enableAltView": settings.enableAltView,
|
||||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||||
"jumpForwardTime": settings.jumpForwardTime
|
"jumpForwardTime": settings.jumpForwardTime
|
||||||
]
|
]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// PlaybackMetadata.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class PlaybackMetadata: Codable {
|
||||||
|
var duration: Double = 0
|
||||||
|
var currentTime: Double = 0
|
||||||
|
var playerState: PlayerState = PlayerState.IDLE
|
||||||
|
}
|
||||||
@@ -6,27 +6,156 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
struct PlaybackSession: Decodable, Encodable {
|
class PlaybackSession: Object, Codable, Deletable {
|
||||||
var id: String
|
@Persisted(primaryKey: true) var id: String = ""
|
||||||
var userId: String?
|
@Persisted var userId: String?
|
||||||
var libraryItemId: String?
|
@Persisted var libraryItemId: String?
|
||||||
var episodeId: String?
|
@Persisted var episodeId: String?
|
||||||
var mediaType: String
|
@Persisted var mediaType: String = ""
|
||||||
// var mediaMetadata: MediaTypeMetadata - It is not implemented in android?
|
@Persisted var mediaMetadata: Metadata?
|
||||||
var chapters: [Chapter]
|
@Persisted var chapters = List<Chapter>()
|
||||||
var displayTitle: String?
|
@Persisted var displayTitle: String?
|
||||||
var displayAuthor: String?
|
@Persisted var displayAuthor: String?
|
||||||
var coverPath: String?
|
@Persisted var coverPath: String?
|
||||||
var duration: Double
|
@Persisted var duration: Double = 0
|
||||||
var playMethod: Int
|
@Persisted var playMethod: Int = PlayMethod.directplay.rawValue
|
||||||
var startedAt: Double?
|
@Persisted var startedAt: Double?
|
||||||
var updatedAt: Double?
|
@Persisted var updatedAt: Double?
|
||||||
var timeListening: Double
|
@Persisted var timeListening: Double = 0
|
||||||
var audioTracks: [AudioTrack]
|
@Persisted var audioTracks = List<AudioTrack>()
|
||||||
var currentTime: Double
|
@Persisted var currentTime: Double = 0
|
||||||
var libraryItem: LibraryItem
|
@Persisted var libraryItem: LibraryItem?
|
||||||
// var localLibraryItem: LocalLibraryItem?
|
@Persisted var localLibraryItem: LocalLibraryItem?
|
||||||
var serverConnectionConfigId: String?
|
@Persisted var serverConnectionConfigId: String?
|
||||||
var serverAddress: String?
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var isActiveSession = true
|
||||||
|
@Persisted var serverUpdatedAt: Double = 0
|
||||||
|
|
||||||
|
var isLocal: Bool { self.localLibraryItem != nil }
|
||||||
|
var mediaPlayer: String { "AVPlayer" }
|
||||||
|
var deviceInfo: [String: String?]? {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
|
||||||
|
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||||
|
ptr in String.init(validatingUTF8: ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"manufacturer": "Apple",
|
||||||
|
"model": modelCode,
|
||||||
|
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var localMediaProgressId: String? {
|
||||||
|
if let localLibraryItem = localLibraryItem, let episodeId = episodeId {
|
||||||
|
return "\(localLibraryItem.id)-\(episodeId)"
|
||||||
|
} else if let localLibraryItem = localLibraryItem {
|
||||||
|
return localLibraryItem.id
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalDuration: Double {
|
||||||
|
var total = 0.0
|
||||||
|
self.audioTracks.forEach { total += $0.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double { self.currentTime / self.totalDuration }
|
||||||
|
|
||||||
|
internal init(id: String, userId: String? = nil, libraryItemId: String? = nil, episodeId: String? = nil, mediaType: String, mediaMetadata: Metadata?, chapters: List<Chapter> = List<Chapter>(), displayTitle: String? = nil, displayAuthor: String? = nil, coverPath: String? = nil, duration: Double, playMethod: Int, startedAt: Double? = nil, updatedAt: Double? = nil, timeListening: Double, audioTracks: List<AudioTrack> = List<AudioTrack>(), currentTime: Double, libraryItem: LibraryItem? = nil, localLibraryItem: LocalLibraryItem? = nil, serverConnectionConfigId: String? = nil, serverAddress: String? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.userId = userId
|
||||||
|
self.libraryItemId = libraryItemId
|
||||||
|
self.episodeId = episodeId
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.mediaMetadata = mediaMetadata
|
||||||
|
self.chapters = chapters
|
||||||
|
self.displayTitle = displayTitle
|
||||||
|
self.displayAuthor = displayAuthor
|
||||||
|
self.coverPath = coverPath
|
||||||
|
self.duration = duration
|
||||||
|
self.playMethod = playMethod
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.timeListening = timeListening
|
||||||
|
self.audioTracks = audioTracks
|
||||||
|
self.currentTime = currentTime
|
||||||
|
self.libraryItem = libraryItem
|
||||||
|
self.localLibraryItem = localLibraryItem
|
||||||
|
self.serverConnectionConfigId = serverConnectionConfigId
|
||||||
|
self.serverAddress = serverAddress
|
||||||
|
self.isActiveSession = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, userId, libraryItemId, episodeId, mediaType, mediaMetadata, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, mediaPlayer, deviceInfo, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
userId = try values.decodeIfPresent(String.self, forKey: .userId)
|
||||||
|
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
|
||||||
|
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||||
|
mediaMetadata = try values.decodeIfPresent(Metadata.self, forKey: .mediaMetadata)
|
||||||
|
if let chapterList = try values.decodeIfPresent([Chapter].self, forKey: .chapters) {
|
||||||
|
chapters.append(objectsIn: chapterList)
|
||||||
|
}
|
||||||
|
displayTitle = try values.decodeIfPresent(String.self, forKey: .displayTitle)
|
||||||
|
displayAuthor = try values.decodeIfPresent(String.self, forKey: .displayAuthor)
|
||||||
|
coverPath = try values.decodeIfPresent(String.self, forKey: .coverPath)
|
||||||
|
duration = try values.decode(Double.self, forKey: .duration)
|
||||||
|
playMethod = try values.decode(Int.self, forKey: .playMethod)
|
||||||
|
startedAt = try values.decodeIfPresent(Double.self, forKey: .startedAt)
|
||||||
|
updatedAt = try values.decodeIfPresent(Double.self, forKey: .updatedAt)
|
||||||
|
timeListening = try values.decode(Double.self, forKey: .timeListening)
|
||||||
|
if let trackList = try values.decodeIfPresent([AudioTrack].self, forKey: .audioTracks) {
|
||||||
|
audioTracks.append(objectsIn: trackList)
|
||||||
|
}
|
||||||
|
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||||
|
libraryItem = try values.decodeIfPresent(LibraryItem.self, forKey: .libraryItem)
|
||||||
|
localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem)
|
||||||
|
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||||
|
isActiveSession = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(userId, forKey: .userId)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(mediaMetadata, forKey: .mediaMetadata)
|
||||||
|
try container.encode(Array(chapters), forKey: .chapters)
|
||||||
|
try container.encode(displayTitle, forKey: .displayTitle)
|
||||||
|
try container.encode(displayAuthor, forKey: .displayAuthor)
|
||||||
|
try container.encode(coverPath, forKey: .coverPath)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(playMethod, forKey: .playMethod)
|
||||||
|
try container.encode(mediaPlayer, forKey: .mediaPlayer)
|
||||||
|
try container.encode(deviceInfo, forKey: .deviceInfo)
|
||||||
|
try container.encode(startedAt, forKey: .startedAt)
|
||||||
|
try container.encode(updatedAt, forKey: .updatedAt)
|
||||||
|
try container.encode(timeListening, forKey: .timeListening)
|
||||||
|
try container.encode(Array(audioTracks), forKey: .audioTracks)
|
||||||
|
try container.encode(currentTime, forKey: .currentTime)
|
||||||
|
try container.encode(libraryItem, forKey: .libraryItem)
|
||||||
|
try container.encode(localLibraryItem, forKey: .localLibraryItem)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(isLocal, forKey: .isLocal)
|
||||||
|
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// PlayerSettings.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/18/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class PlayerSettings: Object {
|
||||||
|
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
|
||||||
|
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
|
||||||
|
@Persisted var playbackRate: Float = 1.0
|
||||||
|
|
||||||
|
// Singleton pattern for Realm objects
|
||||||
|
static func main() -> PlayerSettings {
|
||||||
|
let realm = try! Realm()
|
||||||
|
|
||||||
|
if let settings = realm.objects(PlayerSettings.self).last {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = PlayerSettings()
|
||||||
|
try! realm.write {
|
||||||
|
realm.add(settings)
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// PlayerState.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PlayerState: Codable {
|
||||||
|
case IDLE
|
||||||
|
case BUFFERING
|
||||||
|
case READY
|
||||||
|
case ENDED
|
||||||
|
}
|
||||||
@@ -9,30 +9,28 @@ import Foundation
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class ServerConnectionConfig: Object {
|
class ServerConnectionConfig: Object {
|
||||||
@Persisted(primaryKey: true) var id: String
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
@Persisted(indexed: true) var index: Int
|
@Persisted(indexed: true) var index: Int = 1
|
||||||
@Persisted var name: String
|
@Persisted var name: String = ""
|
||||||
@Persisted var address: String
|
@Persisted var address: String = ""
|
||||||
@Persisted var userId: String
|
@Persisted var userId: String = ""
|
||||||
@Persisted var username: String
|
@Persisted var username: String = ""
|
||||||
@Persisted var token: String
|
@Persisted var token: String = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServerConnectionConfigActiveIndex: Object {
|
class ServerConnectionConfigActiveIndex: Object {
|
||||||
// This could overflow, but you really would have to try
|
// This could overflow, but you really would have to try
|
||||||
@Persisted var index: Int?
|
@Persisted(primaryKey: true) var index: Int?
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
|
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
|
||||||
return Database.realmQueue.sync {
|
return [
|
||||||
return [
|
"id": config.id,
|
||||||
"id": config.id,
|
"name": config.name,
|
||||||
"name": config.name,
|
"index": config.index,
|
||||||
"index": config.index,
|
"address": config.address,
|
||||||
"address": config.address,
|
"userId": config.userId,
|
||||||
"userId": config.userId,
|
"username": config.username,
|
||||||
"username": config.username,
|
"token": config.token,
|
||||||
"token": config.token,
|
]
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// DownloadItem.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class DownloadItem: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String?
|
||||||
|
@Persisted(indexed: true) var libraryItemId: String?
|
||||||
|
@Persisted var episodeId: String?
|
||||||
|
@Persisted var userMediaProgress: MediaProgress?
|
||||||
|
@Persisted var serverConnectionConfigId: String?
|
||||||
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var serverUserId: String?
|
||||||
|
@Persisted var mediaType: String?
|
||||||
|
@Persisted var itemTitle: String?
|
||||||
|
@Persisted var media: MediaType?
|
||||||
|
@Persisted var downloadItemParts = List<DownloadItemPart>()
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, libraryItemId, episodeId, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try? values.decode(String.self, forKey: .id)
|
||||||
|
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||||
|
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
|
||||||
|
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
|
||||||
|
mediaType = try? values.decode(String.self, forKey: .mediaType)
|
||||||
|
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||||
|
if let parts = try? values.decode([DownloadItemPart].self, forKey: .downloadItemParts) {
|
||||||
|
downloadItemParts.append(objectsIn: parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(serverUserId, forKey: .serverUserId)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(itemTitle, forKey: .itemTitle)
|
||||||
|
try container.encode(Array(downloadItemParts), forKey: .downloadItemParts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DownloadItem {
|
||||||
|
convenience init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = libraryItem.id
|
||||||
|
self.libraryItemId = libraryItem.id
|
||||||
|
self.userMediaProgress = libraryItem.userMediaProgress
|
||||||
|
self.serverConnectionConfigId = server.id
|
||||||
|
self.serverAddress = server.address
|
||||||
|
self.serverUserId = server.userId
|
||||||
|
self.mediaType = libraryItem.mediaType
|
||||||
|
self.itemTitle = libraryItem.media?.metadata?.title
|
||||||
|
self.media = libraryItem.media
|
||||||
|
|
||||||
|
if let episodeId = episodeId {
|
||||||
|
self.id! += "-\(episodeId)"
|
||||||
|
self.episodeId = episodeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDoneDownloading() -> Bool {
|
||||||
|
self.downloadItemParts.allSatisfy({ $0.completed })
|
||||||
|
}
|
||||||
|
|
||||||
|
func didDownloadSuccessfully() -> Bool {
|
||||||
|
self.downloadItemParts.allSatisfy({ $0.failed == false })
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.realm?.delete(self.downloadItemParts)
|
||||||
|
self.realm?.delete(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// DownloadItemPart.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class DownloadItemPart: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id = ""
|
||||||
|
@Persisted var filename: String?
|
||||||
|
@Persisted var itemTitle: String?
|
||||||
|
@Persisted var serverPath: String?
|
||||||
|
@Persisted var audioTrack: AudioTrack?
|
||||||
|
@Persisted var episode: PodcastEpisode?
|
||||||
|
@Persisted var completed: Bool = false
|
||||||
|
@Persisted var moved: Bool = false
|
||||||
|
@Persisted var failed: Bool = false
|
||||||
|
@Persisted var uri: String?
|
||||||
|
@Persisted var destinationUri: String?
|
||||||
|
@Persisted var progress: Double = 0
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, filename, itemTitle, completed, moved, failed, progress
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
filename = try? values.decode(String.self, forKey: .filename)
|
||||||
|
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||||
|
completed = try values.decode(Bool.self, forKey: .completed)
|
||||||
|
moved = try values.decode(Bool.self, forKey: .moved)
|
||||||
|
failed = try values.decode(Bool.self, forKey: .failed)
|
||||||
|
progress = try values.decode(Double.self, forKey: .progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(filename, forKey: .filename)
|
||||||
|
try container.encode(itemTitle, forKey: .itemTitle)
|
||||||
|
try container.encode(completed, forKey: .completed)
|
||||||
|
try container.encode(moved, forKey: .moved)
|
||||||
|
try container.encode(failed, forKey: .failed)
|
||||||
|
try container.encode(progress, forKey: .progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DownloadItemPart {
|
||||||
|
convenience init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = destination.toBase64()
|
||||||
|
self.filename = filename
|
||||||
|
self.itemTitle = itemTitle
|
||||||
|
self.serverPath = serverPath
|
||||||
|
self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
|
||||||
|
self.episode = PodcastEpisode.detachCopy(of: episode)
|
||||||
|
|
||||||
|
let config = Store.serverConfig!
|
||||||
|
var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)"
|
||||||
|
if (serverPath.hasSuffix("/cover")) {
|
||||||
|
downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||||
|
}
|
||||||
|
self.uri = downloadUrl
|
||||||
|
self.destinationUri = destination
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadURL: URL? {
|
||||||
|
if let uri = self.uri {
|
||||||
|
return URL(string: uri)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinationURL: URL? {
|
||||||
|
if let destinationUri = self.destinationUri {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: destinationUri)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeType() -> String? {
|
||||||
|
audioTrack?.mimeType ?? episode?.audioTrack?.mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// LocalFile.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalFile: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
|
@Persisted var filename: String?
|
||||||
|
@Persisted var _contentUrl: String = ""
|
||||||
|
@Persisted var mimeType: String?
|
||||||
|
@Persisted var size: Int = 0
|
||||||
|
|
||||||
|
var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString }
|
||||||
|
var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! }
|
||||||
|
var basePath: String? { self.filename }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, filename, contentUrl, mimeType, size, basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
filename = try? values.decode(String.self, forKey: .filename)
|
||||||
|
mimeType = try? values.decode(String.self, forKey: .mimeType)
|
||||||
|
size = try values.decode(Int.self, forKey: .size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(filename, forKey: .filename)
|
||||||
|
try container.encode(contentUrl, forKey: .contentUrl)
|
||||||
|
try container.encode(mimeType, forKey: .mimeType)
|
||||||
|
try container.encode(size, forKey: .size)
|
||||||
|
try container.encode(basePath, forKey: .basePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalFile {
|
||||||
|
convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = "\(libraryItemId)_\(filename.toBase64())"
|
||||||
|
self.filename = filename
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self._contentUrl = localUrl
|
||||||
|
self.size = fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var absolutePath: String {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: self._contentUrl)?.absoluteString ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAudioFile() -> Bool {
|
||||||
|
switch self.mimeType {
|
||||||
|
case "application/octet-stream",
|
||||||
|
"video/mp4":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return self.mimeType?.starts(with: "audio") ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//
|
||||||
|
// LocalLibraryItem.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalLibraryItem: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = "local_\(UUID().uuidString)"
|
||||||
|
@Persisted var basePath: String = ""
|
||||||
|
@Persisted var _contentUrl: String?
|
||||||
|
@Persisted var isInvalid: Bool = false
|
||||||
|
@Persisted var mediaType: String = ""
|
||||||
|
@Persisted var media: MediaType?
|
||||||
|
@Persisted var localFiles = List<LocalFile>()
|
||||||
|
@Persisted var _coverContentUrl: String?
|
||||||
|
@Persisted var isLocal: Bool = true
|
||||||
|
@Persisted var serverConnectionConfigId: String?
|
||||||
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var serverUserId: String?
|
||||||
|
@Persisted(indexed: true) var libraryItemId: String?
|
||||||
|
|
||||||
|
var contentUrl: String? {
|
||||||
|
if let path = _contentUrl {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentDirectory: URL? {
|
||||||
|
if let path = _contentUrl {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: path)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverContentUrl: String? {
|
||||||
|
if let path = self._coverContentUrl {
|
||||||
|
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isBook: Bool { self.mediaType == "book" }
|
||||||
|
var isPodcast: Bool { self.mediaType == "podcast" }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
basePath = try values.decode(String.self, forKey: .basePath)
|
||||||
|
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
|
||||||
|
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||||
|
media = try? values.decode(MediaType.self, forKey: .media)
|
||||||
|
if let files = try? values.decode([LocalFile].self, forKey: .localFiles) {
|
||||||
|
localFiles.append(objectsIn: files)
|
||||||
|
}
|
||||||
|
isLocal = try values.decode(Bool.self, forKey: .isLocal)
|
||||||
|
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
|
||||||
|
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
|
||||||
|
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(basePath, forKey: .basePath)
|
||||||
|
try container.encode(contentUrl, forKey: .contentUrl)
|
||||||
|
try container.encode(isInvalid, forKey: .isInvalid)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(media, forKey: .media)
|
||||||
|
try container.encode(Array(localFiles), forKey: .localFiles)
|
||||||
|
try container.encode(coverContentUrl, forKey: .coverContentUrl)
|
||||||
|
try container.encode(isLocal, forKey: .isLocal)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(serverUserId, forKey: .serverUserId)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalLibraryItem {
|
||||||
|
convenience init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self._contentUrl = localUrl
|
||||||
|
self.mediaType = item.mediaType
|
||||||
|
self.localFiles.append(objectsIn: files)
|
||||||
|
self._coverContentUrl = coverPath
|
||||||
|
self.libraryItemId = item.id
|
||||||
|
self.serverConnectionConfigId = server.id
|
||||||
|
self.serverAddress = server.address
|
||||||
|
self.serverUserId = server.userId
|
||||||
|
|
||||||
|
// Link the audio tracks and files
|
||||||
|
linkLocalFiles(self.localFiles, fromMedia: item.media)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFiles(_ files: [LocalFile], item: LibraryItem) throws {
|
||||||
|
guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported }
|
||||||
|
self.localFiles.append(objectsIn: files.filter({ $0.isAudioFile() }))
|
||||||
|
linkLocalFiles(self.localFiles, fromMedia: item.media)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func linkLocalFiles(_ files: List<LocalFile>, fromMedia: MediaType?) {
|
||||||
|
guard let fromMedia = MediaType.detachCopy(of: fromMedia) else { return }
|
||||||
|
let fileMap = files.map { ($0.filename ?? "", $0.id) }
|
||||||
|
let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last })
|
||||||
|
if ( self.isBook ) {
|
||||||
|
for i in fromMedia.tracks.indices {
|
||||||
|
_ = fromMedia.tracks[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
|
||||||
|
}
|
||||||
|
} else if ( self.isPodcast ) {
|
||||||
|
let episodes = List<PodcastEpisode>()
|
||||||
|
for episode in fromMedia.episodes {
|
||||||
|
// Filter out episodes not downloaded
|
||||||
|
let episodeIsDownloaded = episode.audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) ?? false
|
||||||
|
if episodeIsDownloaded {
|
||||||
|
episodes.append(episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fromMedia.episodes = episodes
|
||||||
|
}
|
||||||
|
self.media = fromMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDuration() -> Double {
|
||||||
|
var total = 0.0
|
||||||
|
self.media?.tracks.enumerated().forEach { _, track in total += track.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPodcastEpisode(episodeId: String?) -> PodcastEpisode? {
|
||||||
|
guard self.isPodcast else { return nil }
|
||||||
|
guard let episodes = self.media?.episodes else { return nil }
|
||||||
|
return episodes.first(where: { $0.id == episodeId })
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession {
|
||||||
|
let localEpisodeId = episode?.id
|
||||||
|
let sessionId = "play_local_\(UUID().uuidString)"
|
||||||
|
|
||||||
|
// Get current progress from local media
|
||||||
|
let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id
|
||||||
|
let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId)
|
||||||
|
|
||||||
|
let mediaMetadata = Metadata.detachCopy(of: self.media?.metadata)
|
||||||
|
let chapters = List<Chapter>()
|
||||||
|
self.media?.chapters.forEach { chapter in chapters.append(Chapter.detachCopy(of: chapter)!) }
|
||||||
|
let authorName = mediaMetadata?.authorDisplayName
|
||||||
|
|
||||||
|
let audioTracks = List<AudioTrack>()
|
||||||
|
if let episode = episode, let track = episode.audioTrack {
|
||||||
|
audioTracks.append(AudioTrack.detachCopy(of: track)!)
|
||||||
|
} else if let tracks = self.media?.tracks {
|
||||||
|
tracks.forEach { t in audioTracks.append(AudioTrack.detachCopy(of: t)!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateNow = Date().timeIntervalSince1970 * 1000
|
||||||
|
return PlaybackSession(
|
||||||
|
id: sessionId,
|
||||||
|
userId: self.serverUserId,
|
||||||
|
libraryItemId: self.libraryItemId,
|
||||||
|
episodeId: episode?.serverEpisodeId,
|
||||||
|
mediaType: self.mediaType,
|
||||||
|
mediaMetadata: mediaMetadata,
|
||||||
|
chapters: chapters,
|
||||||
|
displayTitle: mediaMetadata?.title,
|
||||||
|
displayAuthor: authorName,
|
||||||
|
coverPath: self.coverContentUrl,
|
||||||
|
duration: self.getDuration(),
|
||||||
|
playMethod: PlayMethod.local.rawValue,
|
||||||
|
startedAt: dateNow,
|
||||||
|
updatedAt: dateNow,
|
||||||
|
timeListening: 0.0,
|
||||||
|
audioTracks: audioTracks,
|
||||||
|
currentTime: mediaProgress?.currentTime ?? 0.0,
|
||||||
|
libraryItem: nil,
|
||||||
|
localLibraryItem: self,
|
||||||
|
serverConnectionConfigId: self.serverConnectionConfigId,
|
||||||
|
serverAddress: self.serverAddress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.realm?.delete(self.localFiles)
|
||||||
|
self.realm?.delete(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//
|
||||||
|
// LocalMediaProgress.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalMediaProgress: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = ""
|
||||||
|
@Persisted(indexed: true) var localLibraryItemId: String = ""
|
||||||
|
@Persisted(indexed: true) var localEpisodeId: String?
|
||||||
|
@Persisted var duration: Double = 0
|
||||||
|
@Persisted var progress: Double = 0
|
||||||
|
@Persisted var currentTime: Double = 0
|
||||||
|
@Persisted var isFinished: Bool = false
|
||||||
|
@Persisted var lastUpdate: Double = 0
|
||||||
|
@Persisted var startedAt: Double = 0
|
||||||
|
@Persisted var finishedAt: Double?
|
||||||
|
// For local lib items from server to support server sync
|
||||||
|
@Persisted var serverConnectionConfigId: String?
|
||||||
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var serverUserId: String?
|
||||||
|
@Persisted(indexed: true) var libraryItemId: String?
|
||||||
|
@Persisted(indexed: true) var episodeId: String?
|
||||||
|
|
||||||
|
var progressPercent: Int { Int(self.progress * 100) }
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
localLibraryItemId = try values.decode(String.self, forKey: .localLibraryItemId)
|
||||||
|
localEpisodeId = try values.decodeIfPresent(String.self, forKey: .localEpisodeId)
|
||||||
|
duration = try values.decode(Double.self, forKey: .duration)
|
||||||
|
progress = try values.decode(Double.self, forKey: .progress)
|
||||||
|
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||||
|
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
||||||
|
lastUpdate = try values.decode(Double.self, forKey: .lastUpdate)
|
||||||
|
startedAt = try values.decode(Double.self, forKey: .startedAt)
|
||||||
|
finishedAt = try values.decodeIfPresent(Double.self, forKey: .finishedAt)
|
||||||
|
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||||
|
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||||
|
serverUserId = try values.decodeIfPresent(String.self, forKey: .serverUserId)
|
||||||
|
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
|
||||||
|
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(localLibraryItemId, forKey: .localLibraryItemId)
|
||||||
|
try container.encode(localEpisodeId, forKey: .localEpisodeId)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
if progress.isNaN == false {
|
||||||
|
try container.encode(progress, forKey: .progress)
|
||||||
|
}
|
||||||
|
if currentTime.isNaN == false {
|
||||||
|
try container.encode(currentTime, forKey: .currentTime)
|
||||||
|
}
|
||||||
|
try container.encode(isFinished, forKey: .isFinished)
|
||||||
|
try container.encode(lastUpdate, forKey: .lastUpdate)
|
||||||
|
try container.encode(startedAt, forKey: .startedAt)
|
||||||
|
try container.encode(finishedAt, forKey: .finishedAt)
|
||||||
|
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||||
|
try container.encode(serverAddress, forKey: .serverAddress)
|
||||||
|
try container.encode(serverUserId, forKey: .serverUserId)
|
||||||
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalMediaProgress {
|
||||||
|
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) {
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
self.id = localLibraryItem.id
|
||||||
|
self.localLibraryItemId = localLibraryItem.id
|
||||||
|
self.libraryItemId = localLibraryItem.libraryItemId
|
||||||
|
|
||||||
|
self.serverAddress = localLibraryItem.serverAddress
|
||||||
|
self.serverUserId = localLibraryItem.serverUserId
|
||||||
|
self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId
|
||||||
|
|
||||||
|
self.duration = localLibraryItem.getDuration()
|
||||||
|
self.progress = 0.0
|
||||||
|
self.currentTime = 0.0
|
||||||
|
self.isFinished = false
|
||||||
|
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||||
|
self.startedAt = 0
|
||||||
|
self.finishedAt = nil
|
||||||
|
|
||||||
|
if let episode = episode {
|
||||||
|
self.id += "-\(episode.id)"
|
||||||
|
self.episodeId = episode.id
|
||||||
|
self.duration = episode.duration ?? 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) {
|
||||||
|
self.init(localLibraryItem: localLibraryItem, episode: episode)
|
||||||
|
self.duration = progress.duration
|
||||||
|
self.progress = progress.progress
|
||||||
|
self.currentTime = progress.currentTime
|
||||||
|
self.isFinished = progress.isFinished
|
||||||
|
self.lastUpdate = progress.lastUpdate
|
||||||
|
self.startedAt = progress.startedAt
|
||||||
|
self.finishedAt = progress.finishedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIsFinished(_ finished: Bool) throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
if self.isFinished != finished {
|
||||||
|
self.progress = finished ? 1.0 : 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.startedAt == 0 && finished {
|
||||||
|
self.startedAt = Date().timeIntervalSince1970 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isFinished = finished
|
||||||
|
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||||
|
self.finishedAt = finished ? lastUpdate : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromPlaybackSession(_ playbackSession: PlaybackSession) throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.currentTime = playbackSession.currentTime
|
||||||
|
self.progress = playbackSession.progress
|
||||||
|
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||||
|
self.isFinished = playbackSession.progress >= 100.0
|
||||||
|
self.finishedAt = self.isFinished ? self.lastUpdate : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws {
|
||||||
|
try self.realm?.write {
|
||||||
|
self.isFinished = serverMediaProgress.isFinished
|
||||||
|
self.progress = serverMediaProgress.progress
|
||||||
|
self.currentTime = serverMediaProgress.currentTime
|
||||||
|
self.duration = serverMediaProgress.duration
|
||||||
|
self.lastUpdate = serverMediaProgress.lastUpdate
|
||||||
|
self.finishedAt = serverMediaProgress.finishedAt
|
||||||
|
self.startedAt = serverMediaProgress.startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) throws -> LocalMediaProgress? {
|
||||||
|
let realm = try Realm()
|
||||||
|
return try realm.write { () -> LocalMediaProgress? in
|
||||||
|
if let localMediaProgressId = localMediaProgressId {
|
||||||
|
// Check if it existing in the database, if not, we need to create it
|
||||||
|
if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) {
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let localLibraryItemId = localLibraryItemId {
|
||||||
|
guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil }
|
||||||
|
let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId)
|
||||||
|
let progress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode)
|
||||||
|
realm.add(progress)
|
||||||
|
return progress
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//
|
||||||
|
// LocalPodcastEpisode.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LocalPodcastEpisode: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
|
@Persisted var index: Int = 0
|
||||||
|
@Persisted var episode: String?
|
||||||
|
@Persisted var episodeType: String?
|
||||||
|
@Persisted var title: String = "Unknown"
|
||||||
|
@Persisted var subtitle: String?
|
||||||
|
@Persisted var desc: String?
|
||||||
|
@Persisted var audioFile: AudioFile?
|
||||||
|
@Persisted var audioTrack: AudioTrack?
|
||||||
|
@Persisted var duration: Double = 0
|
||||||
|
@Persisted var size: Int = 0
|
||||||
|
@Persisted(indexed: true) var serverEpisodeId: String?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// AudioFile.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class AudioFile: EmbeddedObject, Codable {
|
||||||
|
@Persisted var index: Int?
|
||||||
|
@Persisted var ino: String = ""
|
||||||
|
@Persisted var metadata: FileMetadata?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case index, ino, metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
index = try? values.decode(Int.self, forKey: .index)
|
||||||
|
ino = try values.decode(String.self, forKey: .ino)
|
||||||
|
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(index, forKey: .index)
|
||||||
|
try container.encode(ino, forKey: .ino)
|
||||||
|
try container.encode(metadata, forKey: .metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//
|
||||||
|
// AudioTrack.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class AudioTrack: EmbeddedObject, Codable {
|
||||||
|
@Persisted var index: Int?
|
||||||
|
@Persisted var startOffset: Double?
|
||||||
|
@Persisted var duration: Double = 0
|
||||||
|
@Persisted var title: String?
|
||||||
|
@Persisted var contentUrl: String?
|
||||||
|
@Persisted var mimeType: String = ""
|
||||||
|
@Persisted var metadata: FileMetadata?
|
||||||
|
@Persisted var localFileId: String?
|
||||||
|
@Persisted var serverIndex: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
index = try? values.decode(Int.self, forKey: .index)
|
||||||
|
startOffset = try? values.decode(Double.self, forKey: .startOffset)
|
||||||
|
duration = try values.decode(Double.self, forKey: .duration)
|
||||||
|
title = try? values.decode(String.self, forKey: .title)
|
||||||
|
contentUrl = try? values.decode(String.self, forKey: .contentUrl)
|
||||||
|
mimeType = try values.decode(String.self, forKey: .mimeType)
|
||||||
|
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
|
||||||
|
localFileId = try? values.decodeIfPresent(String.self, forKey: .localFileId)
|
||||||
|
serverIndex = try? values.decode(Int.self, forKey: .serverIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(index, forKey: .index)
|
||||||
|
try container.encode(startOffset, forKey: .startOffset)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
try container.encode(title, forKey: .title)
|
||||||
|
try container.encode(contentUrl, forKey: .contentUrl)
|
||||||
|
try container.encode(mimeType, forKey: .mimeType)
|
||||||
|
try container.encode(metadata, forKey: .metadata)
|
||||||
|
try container.encode(localFileId, forKey: .localFileId)
|
||||||
|
try container.encode(serverIndex, forKey: .serverIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioTrack {
|
||||||
|
func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) -> Bool {
|
||||||
|
if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] {
|
||||||
|
self.localFileId = localFileId
|
||||||
|
self.serverIndex = serverIndex
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLocalFile() -> LocalFile? {
|
||||||
|
guard let localFileId = self.localFileId else { return nil }
|
||||||
|
return Database.shared.getLocalFile(localFileId: localFileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// Author.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class Author: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var name: String = "Unknown"
|
||||||
|
@Persisted var coverPath: String?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, name, coverPath
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
name = try values.decode(String.self, forKey: .name)
|
||||||
|
coverPath = try? values.decode(String.self, forKey: .coverPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(coverPath, forKey: .coverPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// Chapter.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by Ron Heft on 8/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class Chapter: EmbeddedObject, Codable {
|
||||||
|
@Persisted var id: Int = 0
|
||||||
|
@Persisted var start: Double = 0
|
||||||
|
@Persisted var end: Double = 0
|
||||||
|
@Persisted var title: String?
|
||||||
|
|
||||||
|
private enum CodingKeys : String, CodingKey {
|
||||||
|
case id, start, end, title
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
super.init()
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(Int.self, forKey: .id)
|
||||||
|
start = try values.decode(Double.self, forKey: .start)
|
||||||
|
end = try values.decode(Double.self, forKey: .end)
|
||||||
|
title = try? values.decode(String.self, forKey: .title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(start, forKey: .start)
|
||||||
|
try container.encode(end, forKey: .end)
|
||||||
|
try container.encode(title, forKey: .title)
|
||||||
|
}
|
||||||
|
}
|
||||||