Compare commits

...
Author SHA1 Message Date
advplyr 72744dcd29 iOS version bump 0.9.56 2022-08-11 19:11:25 -05:00
advplyr 6b374d923b Version bump 0.9.56 2022-08-11 19:08:18 -05:00
advplyr 03a47b06ee Fix:iOS sleep timer chapter end #291 2022-08-11 18:49:04 -05:00
advplyr 77d0974782 Update:default podcast episode table sort descending 2022-08-11 17:37:13 -05:00
advplyr 95e070a897 Add:Podcast item page play button to play next unfinished episode #139 2022-08-11 17:36:27 -05:00
advplyr a5f483f518 Fix:Home page podcast episode progress bar #264 2022-08-11 16:49:38 -05:00
advplyr b9f428fd77 Fix:Android auto separate all library items list from selected library items list 2022-08-10 18:56:12 -05:00
advplyr 94e8978ce6 Fix:Unregister android network listener on destroy 2022-08-10 18:48:47 -05:00
advplyr 62376871e3 Fix:Android get track offset out of bounds crash 2022-08-10 18:33:30 -05:00
advplyr 40b731534c Fix:Android crash when attempting sync after exoplayer failed 2022-08-10 18:27:37 -05:00
advplyr 1aea0a18a9 Fix:Android catch json parse exception from ffprobe caused by XMP id3 tag 2022-08-10 18:02:16 -05:00
advplyr b1be594932 Fix:Android catch exception when grantUriPermission fails 2022-08-10 17:31:38 -05:00
advplyr c1b8f8519f Fix:Android auto library items mixing with other libraries #309 2022-08-09 18:50:59 -05:00
advplyr 34d4a0b61b Fix:When stopping cast progress reverts back to initial progress #314 2022-08-09 18:14:07 -05:00
advplyr 7c3b6c9702 Fix:Starting cast player and stopping cast closing audio player in app #314 2022-08-09 17:35:42 -05:00
advplyr 56c73c12db Update:Android auto show progress bar 2022-08-07 17:52:37 -05:00
advplyr afc04d22bf Remove old android splash screen 2022-08-07 12:11:38 -05:00
advplyr b78f004f29 Update:Sort local books shelf show unfinished books first #302 2022-08-07 11:00:35 -05:00
advplyr 4a82ba7c86 Add:Subtitle to item page 2022-08-07 10:35:01 -05:00
advplyr 719db8d3f6 Add:Setting to disable shake to reset sleep timer #298 #169 2022-08-07 09:13:10 -05:00
advplyr db98492d4f Update:Remove limit on home page local media shelves #301 2022-08-07 08:38:30 -05:00
advplyr 5430c10424 Add:Filter by language #312 2022-08-07 08:21:17 -05:00
advplyr 92a31c246c Update:Collections page show total duration #303 2022-08-04 18:23:09 -05:00
advplyr 9082b76707 Update:Collections table row book count center #306 2022-08-04 18:16:05 -05:00
advplyrandGitHub 408faf3665 Merge pull request #308 from benonymity/stats
Add Statistics
2022-08-04 17:23:51 -05:00
advplyr be6c675ded Show user stats nav only when connected to server, remove tooltip, remove icons on stats 2022-08-04 17:22:56 -05:00
benonymity 87e277fad1 fix: lint comments 2022-08-03 22:20:20 -04:00
benonymity 014588180e feat: add stats page to mobile 2022-08-03 22:17:11 -04:00
advplyr 837df329e2 Update:List view/grid view icon swapped 2022-08-01 18:03:54 -05:00
advplyr 5662c1983a Update:Series book list view show sequence before title 2022-07-30 08:42:49 -05:00
advplyr 8cfefdcc0f Fix:Show duration in alt bookshelf view sort 2022-07-30 08:39:50 -05:00
advplyr 7d91b95320 iOS version bump 0.9.55 2022-07-28 19:45:23 -05:00
advplyr beb790ad17 iOS add getAllLocalMediaProgress capacitor func 2022-07-28 19:44:18 -05:00
advplyr 073110376a Fix:iOS sending multiple sync requests at the same time, Update:iOS sync every 5s 2022-07-28 19:30:18 -05:00
advplyr 16f6f3b7e9 iOS Realm migration to add new altViewEnabled device setting 2022-07-28 19:14:29 -05:00
advplyr 4758f3e557 Version bump 0.9.55-beta 2022-07-28 18:36:43 -05:00
advplyr 38cc2adf0f Comment out widget in dev 2022-07-28 18:29:20 -05:00
advplyr 80fbbfc2ef Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-07-28 18:25:47 -05:00
advplyr ca45956479 Fix:Media progress syncer to not sync media with currentTime 0 and to not sync when lastSyncTime is not set, cancel timer right away on stop/pause syncer #268 2022-07-28 18:25:36 -05:00
advplyrandGitHub 3d64c42b14 Merge pull request #299 from samblooms/master
Added alternative view setting
2022-07-27 18:24:25 -05:00
advplyr 97e7a12883 Updates to make alt bookshelf view look like the web app 2022-07-27 18:21:10 -05:00
Sam Bloomingdale e4d2b646e7 remove modified .gitignore 2022-07-27 15:01:30 -05:00
Sam Bloomingdale a5a8f3b6ac alt view series card 2022-07-27 14:54:29 -05:00
Sam Bloomingdale 41b0535213 fix typo in class name 2022-07-27 13:46:00 -05:00
Sam Bloomingdale a146a157b8 add alternative view to Shelf.vue 2022-07-27 13:37:20 -05:00
Sam Bloomingdale 96fff469fc add alternate view to LazybookShelf.vue 2022-07-27 13:29:07 -05:00
Sam Bloomingdale a60ff00f7e add getter method for 'enableAltView' 2022-07-27 13:15:03 -05:00
Sam Bloomingdale d52c326123 add 'Alternative Bookshelf View' setting 2022-07-27 12:58:06 -05:00
advplyr 76882780a4 Add:Start of app widget to control media playback 2022-07-23 18:09:10 -05:00
advplyr 97eb19da86 Fix:Load local media progress on launch before attempting server connection 2022-07-22 17:00:49 -05:00
advplyr 4ed9d702e9 Version bump 0.9.54-beta 2022-07-22 08:25:48 -05:00
advplyr 4c678836fb Update:Metered network connections to send progress syncs every 60s instead of every 5s and show metered/unmetered in connection status icon #238 2022-07-21 19:18:32 -05:00
advplyr fd134097a1 Fix:Android auto load libraries handle no libraries returned, Update:Local media items cover images width alignment #279 2022-07-21 18:06:33 -05:00
advplyr 2a87f1de28 Fix:Android check local media progress after a pause reverting to previous progress #290 2022-07-21 17:54:00 -05:00
advplyr 1f60a552e5 Version bump 0.9.53-beta 2022-07-20 18:02:07 -05:00
advplyr 3b34029ca2 Fix:M4B downloads adding MP3 extension by downloading to SimpleStorage v0.14.0 #292 2022-07-20 17:38:13 -05:00
advplyr 5f6a1ef7e9 Update:Show error icon on player cover when local media progress fails to sync & remove sync failure toast 2022-07-19 18:50:14 -05:00
advplyr b51f65d2a4 Fix:Closing playback first closes media progress syncer #268 2022-07-19 18:09:55 -05:00
advplyr dc9526beb3 Fix:Edit server config showing duplicate config already exists 2022-07-19 17:32:49 -05:00
advplyr 7188089f63 Fix:Set server settings after user logs in 2022-07-19 16:40:50 -05:00
advplyr db1d6da491 Version bump 0.9.52-beta 2022-07-15 19:59:51 -05:00
advplyr 329a51f886 Example of adding custom action icon for android auto 2022-07-15 19:48:36 -05:00
advplyr 0c0badbae4 Fix:Android Auto use saved playback speed #229 2022-07-15 18:40:20 -05:00
advplyr 2da272c270 Fix:Android auto starting items from continue listening #167 2022-07-15 18:13:04 -05:00
advplyr 25ec5b3dcb Fix:ANR with starting foreground service before starting session 2022-07-15 17:46:33 -05:00
advplyr d9cdc16ff9 Fix:Catch api handler invalid json response body 2022-07-15 16:22:44 -05:00
advplyr 066b72d54d Fix:Media progress sync wait for api request to return before stopping 2022-07-15 16:10:42 -05:00
advplyr e7c913643a Add:Last local media progress sync with server widget, Update:Remove local media progress with bad id 2022-07-13 19:17:34 -05:00
advplyr 9847ed9fcb Fix network connection indicator 2022-07-13 17:13:15 -05:00
advplyr 721f812df0 Update:Library widget UI 2022-07-13 17:10:47 -05:00
advplyr a58965306a Fix misleading plugin function name 2022-07-13 16:44:02 -05:00
advplyrandGitHub 2b5377db04 Merge pull request #278 from Jnewbon/master
Added ability to Give write permission to the SD card
2022-07-11 14:57:38 -05:00
Jnewbon 7fe3849705 Re Added read Permission check as i probably shouldn't have removed it 2022-07-11 00:43:36 +01:00
Jnewbon f998deb725 Added Storage Media type Write access 2022-07-11 00:26:53 +01:00
advplyr cb2aaede67 Fix:Socket reconnection on disconnect, Add:Connection indicator icon showing socket/cellular 2022-07-07 17:24:26 -05:00
advplyr e07e7f70d6 Fix:Buffered progress bar for chapter track #250 2022-07-04 18:42:43 -05:00
advplyr 48313b60c1 iOS version bump 0.9.51 2022-07-04 16:56:04 -05:00
advplyr 3be13025a8 Fix:iOS swipe navigation to close modal 2022-07-04 16:55:34 -05:00
advplyr 505717e580 Version bump 0.9.51 2022-07-04 16:05:20 -05:00
advplyr b4bb6bf81b Fix:Back button to close modal #267 and back button to minimize fullscreen player #267 2022-07-04 15:42:59 -05:00
advplyr 6a8d901ce1 Update:Collections table row UI updates and include book duration #240 2022-07-04 14:59:56 -05:00
advplyr 68f3f0e276 Fix:Prevent crashes and catch exceptions for crash reports 2022-07-04 12:47:18 -05:00
advplyr ddf8e66a08 Fix:Remove server connection configs and prevent duplicate configs 2022-07-04 12:15:08 -05:00
advplyr 5b7a492a48 Update DB initializing to fix crashes for Paper db not initialized 2022-07-03 13:45:24 -05:00
advplyr 9be1ee1843 iOS version bump 0.9.50 2022-07-02 18:30:22 -05:00
advplyr b5c6acc2bc Add:iOS device settings for jump forward/backward 2022-07-02 18:29:41 -05:00
advplyr 39b137c7f1 Version bump 0.9.50 2022-07-02 16:40:31 -05:00
advplyr b16a7342aa Update:Tapping title/author in fullscreen player redirects to library item page #246 2022-07-02 15:40:37 -05:00
advplyr 3056c55d2f Fix:Update total track width on open fullscreen 2022-07-02 15:03:21 -05:00
advplyr 6b416e6fc1 Fix:Podcast author on item page and use html description for podcast episodes 2022-07-02 14:33:53 -05:00
advplyr e4c6093a82 Update:Notification player closed when closing in-app player and stop button on notification player #255, Add:Setting for disabling auto rewind 2022-07-02 14:18:21 -05:00
advplyr 3c1120ea48 Add settings page with adjustable jump forward/backward settings 2022-07-01 20:05:11 -05:00
advplyr a41e26e4c6 Fix:Fetch more library items per request to fill screen size #265 2022-07-01 17:01:40 -05:00
advplyr 317dc366e3 Add custom headers modal and model 2022-07-01 16:33:39 -05:00
advplyr cd4c280950 Add:Tap cover to show fullscreen cover image #224 2022-06-24 17:20:13 -05:00
advplyr 0ca8de5916 Fix:Audio player fullscreen in landscape mode #234 2022-06-23 19:27:41 -05:00
advplyr 3840f2f344 Update:Less aggressive seek back after pausing, first seek back starts on a 10s pause and jumps back 3s #230. Minimum seek back after a pause is the current chapter start time #244 2022-06-21 18:22:47 -05:00
advplyr f353a44fac Fix:Chromecast current track #253 2022-06-21 17:25:27 -05:00
advplyr a06796d9ca Fix initialize Paper for android auto 2022-06-19 12:03:50 -05:00
advplyr a69054fefa Fix crash when server requests fail 2022-06-19 11:57:19 -05:00
advplyrandGitHub 6805d7eb96 Update readme.md 2022-06-11 21:46:20 -05:00
advplyrandGitHub c77d0f1944 Merge pull request #233 from jmt-gh/add_server_version
Show server version in drawer
2022-06-09 17:36:56 -05:00
c457c8eaf7 Update components/app/SideDrawer.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-06-09 15:28:49 -07:00
jmt-gh a391db5dc2 Change what is being called for version
If I tried to grab version directly in the computed section, it would
throw a null error on reload in browser (didn't see any issue on an
actual device). Doing it this way fixes that
2022-06-09 12:08:04 -07:00
jmt-gh 2180e2f649 Add server version to drawer
This commit adds the server version next to the URL. It also adds a new
style to ensure that the text wraps properly if the URL is too long.
Trying text-wrap didn't work.
2022-06-09 11:57:44 -07:00
advplyr fc6d16bdd9 Update:Make audio probe optional 2022-06-06 18:47:09 -05:00
advplyr 815529129a Fix:Save playback speed when using custom value #226 2022-06-06 17:19:02 -05:00
advplyr bad8d10a18 Fix:Android 9 and below scan folder for m4b #220 2022-06-05 16:46:38 -05:00
advplyr a4412da3ed Version bump 0.9.49-beta 2022-06-05 16:38:45 -05:00
advplyr 870774b408 Fix:Remove last saved library id when changing servers so home page shelves load on init 2022-06-05 16:32:28 -05:00
advplyr d7dcaa22a6 Fix:Logout button on account page #222 2022-06-05 16:01:05 -05:00
advplyr cc0b2943dd Fix:Hide play button for local podcast 2022-06-05 15:56:23 -05:00
advplyr 50ee0b2265 Fix:Local downloaded books crash when server progress check fails, fix check pause timeout length #219 2022-06-05 15:52:54 -05:00
123 changed files with 3034 additions and 1002 deletions
+9 -5
View File
@@ -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 78 versionCode 87
versionName "0.9.48-beta" versionName "0.9.56-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:0.13.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'
+63 -52
View File
@@ -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>
@@ -1,21 +1,24 @@
package com.audiobookshelf.app package com.audiobookshelf.app
import android.Manifest import android.Manifest
import android.app.DownloadManager import android.content.ComponentName
import android.content.* import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.* import android.os.Bundle
import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.AbsDatabase import com.audiobookshelf.app.data.AbsDatabase
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity import com.getcapacitor.BridgeActivity
import io.paperdb.Paper
class MainActivity : BridgeActivity() { class MainActivity : BridgeActivity() {
@@ -50,14 +53,11 @@ class MainActivity : BridgeActivity() {
// .build()) // .build())
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(tag, "onCreate") Log.d(tag, "onCreate")
// Grant full storage access for testing DbManager.initialize(applicationContext)
// var ss = SimpleStorage(this)
// ss.requestFullStorageAccess()
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) { if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL, PERMISSIONS_ALL,
@@ -68,8 +68,6 @@ class MainActivity : BridgeActivity() {
registerPlugin(AbsDownloader::class.java) registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java) registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java) registerPlugin(AbsDatabase::class.java)
Paper.init(applicationContext)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -94,9 +92,7 @@ class MainActivity : BridgeActivity() {
foregroundService = mLocalBinder.getService() foregroundService = mLocalBinder.getService()
// Let NativeAudio know foreground service is ready and setup event listener // Let NativeAudio know foreground service is ready and setup event listener
if (pluginCallback != null) { pluginCallback()
pluginCallback()
}
} }
} }
@@ -140,9 +136,4 @@ class MainActivity : BridgeActivity() {
// Mandatory for Activity, but not for Fragment & ComponentActivity // Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults) storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
} }
// override fun onUserInteraction() {
// super.onUserInteraction()
// Log.d(tag, "USER INTERACTION")
// }
} }
@@ -0,0 +1,96 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
import android.util.Log
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.WidgetEventEmitter
import com.audiobookshelf.app.player.PlayerNotificationService
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class NewAppWidget : AppWidgetProvider() {
val tag = "NewAppWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
}
}
override fun onEnabled(context: Context) {
Log.w(tag, "onEnabled check context ${context.packageName}")
// Enter relevant functionality for when the first widget is created
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns:PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, NewAppWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
val cover = playbackSession?.getCoverUri()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
}
}
})
}
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -59,8 +59,6 @@ data class LibraryItem(
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString()) 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) putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build() }.build()
} }
@@ -309,7 +307,6 @@ data class PodcastEpisode(
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName()) putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
@@ -399,21 +396,33 @@ data class BookChapter(
var start:Double, var start:Double,
var end:Double, var end:Double,
var title:String? var title:String?
) ) {
@get:JsonIgnore
val startMs get() = (start * 1000L).toLong()
@get:JsonIgnore
val endMs get() = (end * 1000L).toLong()
}
@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,16 +1,27 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.util.Log import android.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsDownloader
import io.paperdb.Paper import io.paperdb.Paper
import org.json.JSONObject
import java.io.File import java.io.File
class DbManager { class DbManager {
val tag = "DbManager" val tag = "DbManager"
companion object {
var isDbInitialized = false
fun initialize(ctx: Context) {
if (isDbInitialized) return
Paper.init(ctx)
isDbInitialized = true
Log.i("DbManager", "Initialized Paper db")
}
}
fun getDeviceData(): DeviceData { fun getDeviceData(): DeviceData {
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null) return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
} }
fun saveDeviceData(deviceData:DeviceData) { fun saveDeviceData(deviceData:DeviceData) {
Paper.book("device").write("data", deviceData) Paper.book("device").write("data", deviceData)
@@ -34,7 +45,7 @@ class DbManager {
} }
} }
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? { fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId } return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
} }
@@ -147,6 +158,7 @@ class DbManager {
// Check local files // Check local files
lli.localFiles = lli.localFiles.filter { localFile -> lli.localFiles = lli.localFiles.filter { localFile ->
val file = File(localFile.absolutePath) val file = File(localFile.absolutePath)
if (!file.exists()) { if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}") Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
@@ -201,7 +213,11 @@ class DbManager {
val localLibraryItems = getLocalLibraryItems() val localLibraryItems = getLocalLibraryItems()
localMediaProgress.forEach { localMediaProgress.forEach {
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId } val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
if (matchingLLI == null) { if (!it.id.startsWith("local")) {
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI == null) {
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing") Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
Paper.book("localMediaProgress").delete(it.id) Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI.isPodcast) { } else if (matchingLLI.isPodcast) {
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.util.*
data class ServerConnectionConfig( data class ServerConnectionConfig(
var id:String, var id:String,
@@ -13,13 +12,41 @@ data class ServerConnectionConfig(
var address:String, var address:String,
var userId:String, var userId:String,
var username:String, var username:String,
var token:String var token:String,
var customHeaders:Map<String, String>?
) )
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
disableShakeToResetSleepTimer = false
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
}
data class DeviceData( data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>, var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?, var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
var deviceSettings:DeviceSettings?
) { ) {
@JsonIgnore @JsonIgnore
fun getLastServerConnectionConfig():ServerConnectionConfig? { fun getLastServerConnectionConfig():ServerConnectionConfig? {
@@ -2,8 +2,11 @@ package com.audiobookshelf.app.data
import android.content.Context import android.content.Context
import android.net.Uri 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 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
@@ -95,7 +98,7 @@ data class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadata(ctx: Context): MediaMetadataCompat { fun getMediaMetadata(): MediaMetadataCompat {
val coverUri = getCoverUri() val coverUri = getCoverUri()
return MediaMetadataCompat.Builder().apply { return MediaMetadataCompat.Builder().apply {
@@ -104,8 +107,6 @@ data class LocalLibraryItem(
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build() }.build()
} }
@@ -2,16 +2,17 @@ package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlin.math.roundToInt
@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?,
@@ -21,7 +22,10 @@ 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
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@JsonIgnore @JsonIgnore
fun updateIsFinished(finished:Boolean) { fun updateIsFinished(finished:Boolean) {
if (isFinished != finished) { // If finished changed then set progress if (isFinished != finished) { // If finished changed then set progress
@@ -67,15 +67,21 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getCurrentTrackIndex():Int { fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) { for (i in 0 until audioTracks.size) {
val track = audioTracks[i] val track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) { if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
return i return i
} }
} }
return audioTracks.size - 1 return audioTracks.size - 1
} }
@JsonIgnore
fun getChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
return chapters.find { time >= it.startMs && it.endMs > time}
}
@JsonIgnore @JsonIgnore
fun getCurrentTrackTimeMs():Long { fun getCurrentTrackTimeMs():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()] val currentTrack = audioTracks[this.getCurrentTrackIndex()]
@@ -85,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()
} }
@@ -117,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")
} }
@@ -26,4 +31,9 @@ object DeviceManager {
fun getBase64Id(id:String):String { fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP) return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
} }
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
if (id == null) return null
return deviceData.serverConnectionConfigs.find { it.id == id }
}
} }
@@ -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"
@@ -105,7 +106,7 @@ class FolderScanner(var ctx: Context) {
var coverContentUrl:String? = null var coverContentUrl:String? = null
var coverAbsolutePath:String? = null var coverAbsolutePath:String? = null
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4")) val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
val existingLocalFilesRemoved = existingLocalFiles.filter { elf -> val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
@@ -157,11 +158,11 @@ class FolderScanner(var ctx: Context) {
audioTrackToAdd = existingAudioTrack audioTrackToAdd = existingAudioTrack
} else { } else {
// Create new audio track // Create new audio track
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null) val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
audioTrackToAdd = track audioTrackToAdd = track
} }
startOffset += audioProbeResult.duration startOffset += audioProbeResult?.duration ?: 0.0
isNewOrUpdated = true isNewOrUpdated = true
} else { } else {
audioTrackToAdd = existingAudioTrack audioTrackToAdd = existingAudioTrack
@@ -289,7 +290,7 @@ class FolderScanner(var ctx: Context) {
val audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track // Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1) val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index)
audioTracks.add(track) audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}") Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
@@ -378,7 +379,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false var wasUpdated = false
// Search for files in media item folder // Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4")) val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}") Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach { filesFound.forEach {
@@ -431,7 +432,7 @@ class FolderScanner(var ctx: Context) {
// Create new audio track // Create new audio track
val lastTrack = existingAudioTracks.lastOrNull() val lastTrack = existingAudioTracks.lastOrNull()
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0) val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null) val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track) localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}") Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true wasUpdated = true
@@ -463,12 +464,24 @@ class FolderScanner(var ctx: Context) {
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem) return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
} }
fun probeAudioFile(absolutePath:String):AudioProbeResult { fun probeAudioFile(absolutePath:String):AudioProbeResult? {
var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output) var probeObject:JSObject? = null
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") try {
return audioProbeResult 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
} else {
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
return audioProbeResult
}
} }
} }
@@ -1,22 +1,29 @@
package com.audiobookshelf.app.media package com.audiobookshelf.app.media
import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.getcapacitor.JSObject
import java.util.* import java.util.*
import io.paperdb.Paper
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.json.JSONException
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager" val tag = "MediaManager"
var serverLibraryItems = listOf<LibraryItem>() var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = "" var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null var selectedLibraryItemWrapper:LibraryItemWrapper? = null
@@ -26,16 +33,38 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
var serverLibraryCategories = listOf<LibraryCategory>() var serverLibraryCategories = listOf<LibraryCategory>()
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()
fun initializeAndroidAuto() { var userSettingsPlaybackRate:Float? = null
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
Paper.init(ctx)
}
fun getIsLibrary(id:String) : Boolean { fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null return serverLibraries.find { it.id == id } != null
} }
fun getSavedPlaybackRate():Float {
if (userSettingsPlaybackRate != null) {
return userSettingsPlaybackRate ?: 1f
}
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
if (sharedPrefs != null) {
val userSettingsPref = sharedPrefs.getString("userSettings", null)
if (userSettingsPref != null) {
try {
val userSettings = JSObject(userSettingsPref)
if (userSettings.has("playbackRate")) {
userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat()
return userSettingsPlaybackRate ?: 1f
}
} catch(je:JSONException) {
Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}")
}
}
}
return 1f
}
fun checkResetServerItems() { fun checkResetServerItems() {
// When opening android auto need to check if still connected to server // When opening android auto need to check if still connected to server
// and reset any server data already set // and reset any server data already set
@@ -45,7 +74,8 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
serverPodcastEpisodes = listOf() serverPodcastEpisodes = listOf()
serverLibraryCategories = listOf() serverLibraryCategories = listOf()
serverLibraries = listOf() serverLibraries = listOf()
serverLibraryItems = listOf() serverLibraryItems = mutableListOf()
selectedLibraryItems = mutableListOf()
selectedLibraryId = "" selectedLibraryId = ""
} }
} }
@@ -62,14 +92,22 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
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
}
serverLibraryItems = libraryItemsWithAudio selectedLibraryItems = mutableListOf()
libraryItemsWithAudio.forEach { libraryItem ->
selectedLibraryItems.add(libraryItem)
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
cb(libraryItemsWithAudio) cb(libraryItemsWithAudio)
} }
} }
@@ -106,7 +144,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
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 mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
} }
@@ -121,7 +163,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
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 mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
} }
@@ -153,16 +199,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) {
@@ -173,14 +252,27 @@ 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 // TODO: Load currently listening category for local items
@@ -210,21 +302,38 @@ 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(cats) // Return download category only
} else {
val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(library.id) { libraryCategories -> loadLibraryCategories(library.id) { libraryCategories ->
// Only using book or podcast library categories for now // Only using book or podcast library categories for now
libraryCategories.forEach { libraryCategories.forEach {
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) { // Add items in continue listening to serverLibraryItems
// Log.d(tag, "Using library category ${it.id}") if (it.id == "continue-listening") {
cats.add(it) it.entities.forEach { libraryItemWrapper ->
val libraryItem = libraryItemWrapper as LibraryItem
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
}
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) {
// Log.d(tag, "Using library category ${it.id}")
cats.add(it)
}
} }
}
cb(cats) cb(cats)
}
} }
} }
} else { // Not connected/no internet sent downloaded cats only } else { // Not connected/no internet sent downloaded cats only
@@ -270,16 +379,54 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession) -> Unit) { fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) { if (libraryItemWrapper is LocalLibraryItem) {
val localLibraryItem = libraryItemWrapper as LocalLibraryItem cb(libraryItemWrapper.getPlaybackSession(episode))
cb(localLibraryItem.getPlaybackSession(episode)) } else {
} else { val libraryItem = libraryItemWrapper as LibraryItem
val libraryItem = libraryItemWrapper as LibraryItem apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "",playItemRequestPayload) { if (it == null) {
cb(it) cb(null)
} } else {
} cb(it)
}
}
}
}
fun getMediaDescriptionFromMediaMetadata(item: MediaMetadataCompat, progress:MediaProgressWrapper?): MediaDescriptionCompat {
val extras = Bundle()
if (progress != null) {
Log.d(tag, "Has media progress for ${item.description.title} | ${progress}")
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 {
Log.d(tag, "No media progress for ${item.description.title} | ${item.description.mediaId}")
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
return MediaDescriptionCompat.Builder()
.setMediaId(item.description.mediaId)
.setTitle(item.description.title)
.setIconUri(item.description.iconUri)
.setSubtitle(item.description.subtitle)
.setExtras(extras).build()
} }
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
@@ -21,9 +21,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
var currentIconUri: Uri? = null var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null var currentBitmap: Bitmap? = null
private val glideOptions = RequestOptions()
.fallback(R.drawable.icon)
.diskCacheStrategy(DiskCacheStrategy.DATA)
private val serviceJob = SupervisorJob() private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@@ -4,13 +4,9 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.util.Log
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,
@@ -86,7 +82,7 @@ class BrowseTree(
localBooksCat.entities.forEach { libc -> localBooksCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf() val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context) children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children mediaIdToChildren[DOWNLOADS_ROOT] = children
} }
} }
@@ -95,7 +91,7 @@ class BrowseTree(
localPodcastsCat.entities.forEach { libc -> localPodcastsCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf() val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context) children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children mediaIdToChildren[DOWNLOADS_ROOT] = children
} }
} }
@@ -88,7 +88,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var currentMediaItemIndex = 0 private var currentMediaItemIndex = 0
private var pendingMediaItemRemovalPosition:PositionInfo? = null private var pendingMediaItemRemovalPosition:PositionInfo? = null
private var pendingSeekCount = 0 private var pendingSeekCount = 0
private var pendingSeekWindowIndex = 0 private var pendingSeekWindowIndex = C.INDEX_UNSET
private var pendingSeekPositionMs = 0L private var pendingSeekPositionMs = 0L
init { init {
@@ -10,7 +10,6 @@ import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
import kotlin.math.roundToInt
data class MediaProgressSyncData( data class MediaProgressSyncData(
var timeListened:Long, // seconds var timeListened:Long, // seconds
@@ -20,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
@@ -43,88 +43,163 @@ 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) {
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 = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
if (currentTime > 0) { // Current time should always be > 0 on stop
sync(true, currentTime) {
reset()
cb()
}
} else {
reset()
cb()
}
}
fun pause(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle")
Log.d(tag, "pause: Last sync time $lastSyncTime")
val currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime) if (currentTime > 0) { // Current time should always be > 0 on pause
reset() 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) { fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime if (lastSyncTime <= 0) {
if (diffSinceLastSync < 1000L) { Log.e(tag, "Last sync time is not set $lastSyncTime")
return return
} }
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
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)
currentPlaybackSession?.syncData(syncData) currentPlaybackSession?.syncData(syncData)
if (currentPlaybackSession?.progress?.isNaN() == true) {
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
return cb()
}
if (currentIsLocal) { if (currentIsLocal) {
// Save local progress sync // Save local progress sync
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
if (!it.libraryItemId.isNullOrEmpty()) { // Send sync to server also if connected to this server and local item belongs to this server
// Send sync to server also if connected to this server and local item belongs to this server if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { apiHandler.sendLocalProgressSync(it) { syncSuccess ->
apiHandler.sendLocalProgressSync(it) { 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()
} }
} else {
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()
} }
} else {
cb()
} }
} }
@@ -140,19 +215,22 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession) currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
} }
currentLocalMediaProgress?.let { currentLocalMediaProgress?.let {
DeviceManager.dbManager.saveLocalMediaProgress(it) if (it.progress.isNaN()) {
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) Log.e(tag, "Invalid progress on local media progress")
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%") } else {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
}
} }
} }
fun reset() { 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,8 +7,10 @@ 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.*
@@ -25,9 +27,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,true,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true, playbackRate)
}
} }
} }
} }
@@ -47,9 +53,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.d(tag, "ON PLAY FROM SEARCH $query") Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,true,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
} }
} }
} }
@@ -105,9 +115,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,true,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
} }
} }
} }
@@ -118,8 +132,25 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
fun handleCallMediaButton(intent: Intent): Boolean { 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}")
@@ -205,4 +236,12 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
} }
// Example Using a custom action in android auto
// override fun onCustomAction(action: String?, extras: Bundle?) {
// super.onCustomAction(action, extras)
//
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) {
//
// }
// }
} }
@@ -31,8 +31,13 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.d(tag, "ON PREPARE $playWhenReady") Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Handler(Looper.getMainLooper()).post() { if (it == null) {
playerNotificationService.preparePlayer(it,playWhenReady,null) Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
} }
} }
} }
@@ -54,9 +59,13 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,playWhenReady,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
} }
} }
} }
@@ -66,9 +75,13 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.d(tag, "ON PREPARE FROM SEARCH $query") Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,playWhenReady,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
} }
} }
} }
@@ -2,10 +2,11 @@ package com.audiobookshelf.app.player
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.PlayerState import com.audiobookshelf.app.data.PlayerState
import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 60000 // 1 minute const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener" var tag = "PlayerListener"
@@ -23,7 +24,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}") Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
Log.d(tag, "EVENT_POSITION_DISCONTINUITY") Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
@@ -69,26 +70,37 @@ 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) { 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) {
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500 // Current chapter is used so that seek back does not go back to the previous chapter
Log.d(tag, "SeekBackTime $backTime") val currentChapter = playerNotificationService.getCurrentBookChapter()
val minSeekBackTime = currentChapter?.startMs ?: 0
val currentTime = playerNotificationService.getCurrentTime()
val newTime = currentTime - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
onSeekBack = true onSeekBack = true
playerNotificationService.seekBackward(backTime)
} else {
Log.d(tag, "SeekBackTime: back time is 0")
} }
} }
// Check if playback session still exists or sync media progress if updated // Check if playback session still exists or sync media progress if updated
if (lastPauseTime > PAUSE_LEN_BEFORE_RECHECK) { val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress() if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
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")
@@ -96,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)
} }
} }
@@ -112,8 +127,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (lastPauseTime <= 0) return 0 if (lastPauseTime <= 0) return 0
val time: Long = System.currentTimeMillis() - lastPauseTime val time: Long = System.currentTimeMillis() - lastPauseTime
val seekback: Long val seekback: Long
if (time < 3000) seekback = 0 if (time < 10000) seekback = 0 // 10s or less = no seekback
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s
else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
else seekback = 29500 // 30m and up = jump back 30s else seekback = 29500 // 30m and up = jump back 30s
return seekback return seekback
@@ -14,6 +14,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
// Start foreground service // Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
PlayerNotificationService.isClosed = false
playerNotificationService.startForeground(notificationId, notification) playerNotificationService.startForeground(notificationId, notification)
} }
@@ -26,6 +27,17 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
playerNotificationService.stopSelf() playerNotificationService.stopSelf()
} else { } else {
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
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
Log.d(tag, "PNS is not closed - closing it now")
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
@@ -15,9 +19,12 @@ import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log 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.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
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -41,6 +48,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
companion object { companion object {
var isStarted = false var isStarted = false
var isClosed = false
var isUnmeteredNetwork = false
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
} }
interface ClientEventEmitter { interface ClientEventEmitter {
@@ -54,6 +64,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"
@@ -64,7 +76,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var ctx:Context private lateinit var ctx:Context
private lateinit var mediaSessionConnector: MediaSessionConnector private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls private lateinit var transportControls:MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager lateinit var mediaManager: MediaManager
@@ -112,11 +124,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun getService(): PlayerNotificationService = this@PlayerNotificationService fun getService(): PlayerNotificationService = this@PlayerNotificationService
} }
fun stopService(context: Context) {
val stopIntent = Intent(context, PlayerNotificationService::class.java)
context.stopService(stopIntent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isStarted = true isStarted = true
Log.d(tag, "onStartCommand $startId") Log.d(tag, "onStartCommand $startId")
@@ -141,6 +148,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()
@@ -159,30 +174,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
stopSelf() stopSelf()
} }
override fun onCreate() { override fun onCreate() {
Log.d(tag, "onCreate")
super.onCreate() super.onCreate()
ctx = this ctx = this
// Initialize player // To listen for network change from metered to unmetered
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs( val networkRequest = NetworkRequest.Builder()
1000 * 20, // 20s min buffer .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1000 * 45, // 45s max buffer .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
1000 * 5, // 5s playback start .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
1000 * 20 // 20s playback rebuffer
).build()
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(10000)
.setSeekForwardIncrementMs(10000)
.build() .build()
mPlayer.setHandleAudioBecomingNoisy(true) val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
mPlayer.addListener(PlayerListener(this)) connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
currentPlayer = mPlayer DbManager.initialize(ctx)
// Initialize API // Initialize API
apiHandler = ApiHandler(ctx) apiHandler = ApiHandler(ctx)
@@ -213,6 +219,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)
@@ -234,6 +241,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false) playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false) playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false) playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true) playerNotificationManager.setUseFastForwardActionInCompactView(true)
@@ -247,15 +255,24 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
if (currentPlaybackSession == null) {
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
return MediaDescriptionCompat.Builder().build()
}
val coverUri = currentPlaybackSession!!.getCoverUri() val coverUri = currentPlaybackSession!!.getCoverUri()
// 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)
@@ -276,17 +293,64 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
) )
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
mediaSessionConnector.setPlayer(mPlayer)
// Example adding custom action with icon in android auto
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider {
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
// }
// override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
// var icon = R.drawable.exo_icon_rewind
// return PlaybackStateCompat.CustomAction.Builder(
// "com.audiobookshelf.app.PLAYBACK_RATE", "Playback Rate", icon)
// .build()
// }
// })
mediaSession.setCallback(MediaSessionCallback(this))
initializeMPlayer()
currentPlayer = mPlayer
}
private fun initializeMPlayer() {
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start
1000 * 20 // 20s playback rebuffer
).build()
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(seekBackTime)
.setSeekForwardIncrementMs(seekForwardTime)
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
//attach player to playerNotificationManager //attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer) playerNotificationManager.setPlayer(mPlayer)
mediaSession.setCallback(MediaSessionCallback(this))
mediaSessionConnector.setPlayer(mPlayer)
} }
/* /*
User callable methods User callable methods
*/ */
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) { fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
if (!isStarted) {
Log.i(tag, "preparePlayer: foreground service not started - Starting service --")
Intent(ctx, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(ctx, intent)
}
}
isClosed = false
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate initialPlaybackRate = playbackRate
@@ -360,7 +424,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()
@@ -381,13 +444,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post { if (it == null) { // Play request failed
preparePlayer(it, true, null) clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback(true)
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
} }
} }
} else { } else {
clientEventEmitter?.onPlaybackFailed(errorMessage) clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback() closePlayback(true)
} }
} }
} }
@@ -400,8 +468,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post { if (it == null) {
preparePlayer(it, true, null) Log.e(tag, "Failed to start new playback session")
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
} }
} }
} }
@@ -427,6 +499,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)
@@ -441,15 +525,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)
} }
} }
@@ -486,10 +568,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()
} }
@@ -498,34 +576,59 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.id return currentPlaybackSession?.id
} }
fun getCurrentBookChapter():BookChapter? {
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
}
// 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
} }
if (playbackSession.isLocal) { if (playbackSession.isLocal) {
// Make sure this connection config exists
val serverConnectionConfig = DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId)
if (serverConnectionConfig == null) {
Log.d(tag, "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}")
return true // carry on
}
// Local playback session check if server has updated media progress // Local playback session check if server has updated media progress
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server") Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId) { mediaProgress -> apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId, serverConnectionConfig) { mediaProgress ->
if (mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
if (mediaProgress != null && mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}") 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 {
@@ -548,6 +651,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)
@@ -565,10 +672,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()
} }
@@ -610,12 +713,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer.setPlaybackSpeed(speed) currentPlayer.setPlaybackSpeed(speed)
} }
fun closePlayback() { fun closePlayback(calledOnError:Boolean? = false) {
currentPlayer.clearMediaItems() Log.d(tag, "closePlayback")
currentPlayer.stop() 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 {
currentPlayer.stop()
currentPlayer.clearMediaItems()
} catch(e:Exception) {
Log.e(tag, "Exception clearing exoplayer $e")
}
currentPlaybackSession = null currentPlaybackSession = null
clientEventEmitter?.onPlaybackClosed() clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0 PlayerListener.lastPauseTime = 0
isClosed = true
stopForeground(true)
stopSelf()
} }
fun sendClientMetadata(playerState: PlayerState) { fun sendClientMetadata(playerState: PlayerState) {
@@ -650,6 +769,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onProgressSyncFailing() clientEventEmitter?.onProgressSyncFailing()
} }
fun alertSyncSuccess() {
clientEventEmitter?.onProgressSyncSuccess()
}
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // MEDIA BROWSER STUFF (ANDROID AUTO)
// //
@@ -658,7 +781,7 @@ 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 ALL_ROOT = "__ALL__"
@@ -683,11 +806,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// No further calls will be made to other media browsing methods. // No further calls will be made to other media browsing methods.
null null
} else { } else {
if (!isStarted) { isStarted = true
Log.d(tag, "AA Not yet started")
mediaManager.initializeAndroidAuto()
isStarted = true
}
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
isAndroidAuto = true isAndroidAuto = true
@@ -709,7 +828,7 @@ 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 val flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach() result.detach()
@@ -725,10 +844,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val libraryItemMediaMetadata = libraryItem.getMediaMetadata() val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemMediaMetadata.description.mediaId }
val description = mediaManager.getMediaDescriptionFromMediaMetadata(libraryItemMediaMetadata, 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>?)
} }
@@ -739,26 +860,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf() val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem -> localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx) val mediaMetadata = localLibraryItem.getMediaMetadata()
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) val progress = DeviceManager.dbManager.getLocalMediaProgress(mediaMetadata.description.mediaId ?: "")
val description = mediaManager.getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
localPodcasts.forEach { localLibraryItem -> localPodcasts.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx) val mediaMetadata = localLibraryItem.getMediaMetadata()
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(localBrowseItems) result.sendResult(localBrowseItems)
} else { // Load categories } else { // Load categories
mediaManager.loadAndroidAutoItems { libraryCategories ->
mediaManager.loadAndroidAutoItems() { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) browseTree = BrowseTree(this, libraryCategories, 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} $flag")
MediaBrowserCompat.MediaItem(item.description, flag) if (flag == MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == item.description.mediaId }
val description = mediaManager.getMediaDescriptionFromMediaMetadata(item, progress)
MediaBrowserCompat.MediaItem(description, flag)
} else {
MediaBrowserCompat.MediaItem(item.description, flag)
}
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -823,5 +952,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
} }
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
Log.i(tag, "Network capabilities changed is unmetered = $unmetered")
isUnmeteredNetwork = unmetered
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
}
}
} }
@@ -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) {
@@ -1,10 +1,8 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -82,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
@@ -150,14 +156,6 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun prepareLibraryItem(call: PluginCall) { fun prepareLibraryItem(call: PluginCall) {
// Need to make sure the player service has been started
if (!PlayerNotificationService.isStarted) {
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(mainActivity, intent)
}
}
val libraryItemId = call.getString("libraryItemId", "").toString() val libraryItemId = call.getString("libraryItemId", "").toString()
val episodeId = call.getString("episodeId", "").toString() val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true val playWhenReady = call.getBoolean("playWhenReady") == true
@@ -165,7 +163,7 @@ class AbsAudioPlayer : Plugin() {
if (libraryItemId.isEmpty()) { if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id") Log.e(tag, "Invalid call to play library item no library item id")
return call.resolve() return call.resolve(JSObject("{\"error\":\"Invalid request\"}"))
} }
if (libraryItemId.startsWith("local")) { // Play local media item if (libraryItemId.startsWith("local")) { // Play local media item
@@ -176,14 +174,28 @@ class AbsAudioPlayer : Plugin() {
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId } episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
if (episode == null) { if (episode == null) {
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId") Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
return call.resolve(JSObject()) return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
} }
} }
Handler(Looper.getMainLooper()).post { 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())
} }
@@ -191,13 +203,27 @@ class AbsAudioPlayer : Plugin() {
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false) val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} 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)))
} }
} }
} }
@@ -25,6 +25,7 @@ class AbsDatabase : Plugin() {
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>) data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>) data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>) data class LocalFoldersPayload(val value:List<LocalFolder>)
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
@@ -37,7 +38,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getDeviceData(call:PluginCall) { fun getDeviceData(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var deviceData = DeviceManager.dbManager.getDeviceData() val deviceData = DeviceManager.dbManager.getDeviceData()
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData))) call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
} }
} }
@@ -45,17 +46,17 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getLocalFolders(call:PluginCall) { fun getLocalFolders(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var folders = DeviceManager.dbManager.getAllLocalFolders() val folders = DeviceManager.dbManager.getAllLocalFolders()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
} }
} }
@PluginMethod @PluginMethod
fun getLocalFolder(call:PluginCall) { fun getLocalFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString() val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
DeviceManager.dbManager.getLocalFolder(folderId)?.let { DeviceManager.dbManager.getLocalFolder(folderId)?.let {
var folderObj = jacksonMapper.writeValueAsString(it) val folderObj = jacksonMapper.writeValueAsString(it)
call.resolve(JSObject(folderObj)) call.resolve(JSObject(folderObj))
} ?: call.resolve() } ?: call.resolve()
} }
@@ -63,10 +64,10 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getLocalLibraryItem(call:PluginCall) { fun getLocalLibraryItem(call:PluginCall) {
var id = call.getString("id", "").toString() val id = call.getString("id", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
if (localLibraryItem == null) { if (localLibraryItem == null) {
call.resolve() call.resolve()
} else { } else {
@@ -76,10 +77,10 @@ class AbsDatabase : Plugin() {
} }
@PluginMethod @PluginMethod
fun getLocalLibraryItemByLLId(call:PluginCall) { fun getLocalLibraryItemByLId(call:PluginCall) {
var libraryItemId = call.getString("libraryItemId", "").toString() val libraryItemId = call.getString("libraryItemId", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId)
if (localLibraryItem == null) { if (localLibraryItem == null) {
call.resolve() call.resolve()
} else { } else {
@@ -90,40 +91,41 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getLocalLibraryItems(call:PluginCall) { fun getLocalLibraryItems(call:PluginCall) {
var mediaType = call.getString("mediaType", "").toString() val mediaType = call.getString("mediaType", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType) val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
} }
} }
@PluginMethod @PluginMethod
fun getLocalLibraryItemsInFolder(call:PluginCall) { fun getLocalLibraryItemsInFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString() val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId) val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
} }
} }
@PluginMethod @PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) { fun setCurrentServerConnectionConfig(call:PluginCall) {
var serverConnectionConfigId = call.getString("id", "").toString() Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId } val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
var userId = call.getString("userId", "").toString() val userId = serverConfigPayload.userId
var username = call.getString("username", "").toString() val username = serverConfigPayload.username
var token = call.getString("token", "").toString() val token = serverConfigPayload.token
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection if (serverConnectionConfig == null) { // New Server Connection
var serverAddress = call.getString("address", "").toString() val serverAddress = call.getString("address", "").toString()
// Create new server connection config // Create new server connection config
var sscId = DeviceManager.getBase64Id("$serverAddress@$username") val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token) serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
// Add and save // Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
@@ -140,8 +142,8 @@ class AbsDatabase : Plugin() {
} }
// Set last connection config // Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) { if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
shouldSave = true shouldSave = true
} }
@@ -156,7 +158,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun removeServerConnectionConfig(call:PluginCall) { fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig> DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null DeviceManager.deviceData.lastServerConnectionConfigId = null
@@ -182,7 +184,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getAllLocalMediaProgress(call:PluginCall) { fun getAllLocalMediaProgress(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
} }
} }
@@ -331,13 +333,13 @@ class AbsDatabase : Plugin() {
// Send update to server media progress is linked to a server and user is logged into that server // Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId -> localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) { if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: "" val libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: "" val episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject() val updatePayload = JSObject()
updatePayload.put("isFinished", isFinished) updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) { apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server") Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("local", true) jsobj.put("local", true)
jsobj.put("server", true) jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring)) jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -346,7 +348,7 @@ class AbsDatabase : Plugin() {
} }
} }
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) { if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("local", true) jsobj.put("local", true)
jsobj.put("server", false) jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring)) jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -356,25 +358,25 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun updateLocalTrackOrder(call:PluginCall) { fun updateLocalTrackOrder(call:PluginCall) {
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: "" val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) { if (localLibraryItem == null) {
call.resolve() call.resolve()
return return
} }
var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
var tracks:JSArray = call.getArray("tracks") ?: JSArray() val tracks:JSArray = call.getArray("tracks") ?: JSArray()
Log.d(tag, "updateLocalTrackOrder $tracks") Log.d(tag, "updateLocalTrackOrder $tracks")
var index = 1 var index = 1
var hasUpdates = false var hasUpdates = false
for (i in 0 until tracks.length()) { for (i in 0 until tracks.length()) {
var track = tracks.getJSONObject(i) val track = tracks.getJSONObject(i)
var localFileId = track.getString("localFileId") val localFileId = track.getString("localFileId")
var existingTrack = audioTracks.find{ it.localFileId == localFileId } val existingTrack = audioTracks.find{ it.localFileId == localFileId }
if (existingTrack != null) { if (existingTrack != null) {
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index") Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
if (existingTrack.index != index) hasUpdates = true if (existingTrack.index != index) hasUpdates = true
@@ -394,4 +396,15 @@ class AbsDatabase : Plugin() {
call.resolve() call.resolve()
} }
} }
@PluginMethod
fun updateDeviceSettings(call:PluginCall) { // Returns device data
Log.d(tag, "updateDeviceSettings ${call.data}")
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.deviceData.deviceSettings = newDeviceSettings
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
}
}
} }
@@ -143,31 +143,35 @@ class AbsDownloader : Plugin() {
} }
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem -> apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
Log.d(tag, "Got library item from server ${libraryItem.id}") if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) { if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") { if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested") Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) { } else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode -> val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId podcastEpisode.id == episodeId
} }
if (episode == null) { if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}")) call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
}
} else { } else {
startLibraryItemDownload(libraryItem, localFolder, episode) startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve() call.resolve()
} }
} else { } else {
startLibraryItemDownload(libraryItem, localFolder, null) call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
call.resolve()
} }
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
} }
} }
} }
@@ -397,9 +401,13 @@ class AbsDownloader : Plugin() {
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}") Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl)) val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE if (localFolderFile == null) {
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype) Log.e(tag, "Local Folder File from uri is null")
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb) } else {
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
file?.moveFileTo(mainActivity,localFolderFile,fileDescription,fcb)
}
} else { } else {
// Why is kotlin requiring an else here.. // Why is kotlin requiring an else here..
} }
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.app.AlertDialog
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -65,39 +66,59 @@ class AbsFileSystem : Plugin() {
@PluginMethod @PluginMethod
fun selectFolder(call: PluginCall) { fun selectFolder(call: PluginCall) {
var mediaType = call.data.getString("mediaType", "book").toString() val mediaType = call.data.getString("mediaType", "book").toString()
val REQUEST_CODE_SELECT_FOLDER = 6
val REQUEST_CODE_SDCARD_ACCESS = 7
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback { mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity) val absolutePath = folder.getAbsolutePath(activity)
var storageType = folder.getStorageType(activity) val storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity) val simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity) val basePath = folder.getBasePath(activity)
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder) DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
} }
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) { override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED") val jsobj = JSObject()
var jsobj = JSObject() if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
jsobj.put("error", "Access Denied")
call.resolve(jsobj) val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(
"You have no write access to this storage, thus selecting this folder is useless." +
"\nWould you like to grant access to this folder?")
builder.setNegativeButton("Dont Allow") { _, _ ->
run {
jsobj.put("error", "User Canceled, Access Denied")
call.resolve(jsobj)
}
}
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, storageType) }
builder.show()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
} }
override fun onStoragePermissionDenied(requestCode: Int) { override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("error", "Permission Denied") jsobj.put("error", "Permission Denied")
call.resolve(jsobj) call.resolve(jsobj)
} }
} }
mainActivity.storage.openFolderPicker(6) mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
} }
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.server package com.audiobookshelf.app.server
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.util.Log import android.util.Log
@@ -17,6 +16,8 @@ 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.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -28,8 +29,6 @@ class ApiHandler(var ctx:Context) {
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var storageSharedPreferences: SharedPreferences? = null
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>) data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>) data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
@@ -45,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)
} }
@@ -81,19 +82,27 @@ 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 {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT") Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace() e.printStackTrace()
cb(JSObject())
val jsobj = JSObject()
jsobj.put("error", "Failed to connect")
cb(jsobj)
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
if (!it.isSuccessful) { if (!it.isSuccessful) {
// throw IOException("Unexpected code $response")
val jsobj = JSObject() val jsobj = JSObject()
jsobj.put("error", "Unexpected code $response") jsobj.put("error", "Unexpected code $response")
cb(jsobj) cb(jsobj)
@@ -104,14 +113,21 @@ class ApiHandler(var ctx:Context) {
if (bodyString == "OK") { if (bodyString == "OK") {
cb(JSObject()) cb(JSObject())
} else { } else {
var jsonObj = JSObject() try {
if (bodyString.startsWith("[")) { var jsonObj = JSObject()
val array = JSArray(bodyString) if (bodyString.startsWith("[")) {
jsonObj.put("value", array) val array = JSArray(bodyString)
} else { jsonObj.put("value", array)
jsonObj = JSObject(bodyString) } else {
jsonObj = JSObject(bodyString)
}
cb(jsonObj)
} catch(je:JSONException) {
Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString")
val jsobj = JSObject()
jsobj.put("error", "Invalid response body")
cb(jsobj)
} }
cb(jsonObj)
} }
} }
} }
@@ -133,19 +149,29 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) { fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1", null, null) { getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString()) if (it.has("error")) {
cb(libraryItem) Log.e(tag, it.getString("error") ?: "getLibraryItem Failed")
cb(null)
} else {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
} }
} }
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) { fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) {
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress" var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId" if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl, null, null) { getRequest(requestUrl, null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString()) if (it.has("error")) {
cb(libraryItem) Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed")
cb(null)
} else {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
} }
} }
@@ -184,22 +210,27 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession) -> Unit) { fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload)) val 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) {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id) if (it.has("error")) {
it.put("serverAddress", DeviceManager.serverAddress) Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString()) cb(null)
cb(playbackSession) } else {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
it.put("serverAddress", DeviceManager.serverAddress)
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
cb(playbackSession)
}
} }
} }
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 {
@@ -208,11 +239,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)
}
} }
} }
@@ -233,11 +268,13 @@ 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.toString()}") Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
if (it.toString() == "{}") { if (it.toString() == "{}") {
Log.e(tag, "Progress sync received empty object") Log.e(tag, "Progress sync received empty object")
} else if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Progress sync error")
} else { } else {
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString()) val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
@@ -269,11 +306,18 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun getMediaProgress(libraryItemId:String, episodeId:String?, cb: (MediaProgress) -> Unit) { fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) {
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId" val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
getRequest(endpoint, null, null) {
val progress = jacksonMapper.readValue<MediaProgress>(it.toString()) // TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
cb(progress) getRequest(endpoint, pingClient, serverConnectionConfig) {
if (it.has("error")) {
Log.e(tag, "getMediaProgress: Failed to get progress")
cb(null)
} else {
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
cb(progress)
}
} }
} }
@@ -302,4 +346,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)
}
}
}
} }
@@ -11,11 +11,5 @@
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/> android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
</group> </group>
</vector> </vector>
@@ -11,11 +11,5 @@
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/> android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
</group> </group>
</vector> </vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

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>
@@ -2,6 +2,4 @@
android:viewportHeight="24" android:viewportWidth="24" android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/> <path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
<path android:fillColor="@android:color/white" android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
<path android:fillColor="@android:color/white" android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
</vector> </vector>
@@ -2,6 +2,4 @@
android:viewportHeight="24" android:viewportWidth="24" android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/> <path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
<path android:fillColor="@android:color/white" android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
<path android:fillColor="@android:color/white" android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
</vector> </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>
Binary file not shown.

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,6 @@
<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>
</resources> </resources>
+10 -1
View File
@@ -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"
/>
+15
View File
@@ -1,4 +1,5 @@
@import "./fonts.css"; @import "./fonts.css";
@import './defaultStyles.css';
body { body {
background-color: #262626; background-color: #262626;
@@ -62,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
*/ */
@@ -77,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);
+55
View File
@@ -0,0 +1,55 @@
/*
This is for setting regular html styles for places where embedding HTML will be
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
*/
.default-style p {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
.default-style a {
text-decoration: none;
color: #5985ff;
}
.default-style ul {
display: block;
list-style: circle;
list-style-type: disc;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style ol {
display: block;
list-style: decimal;
list-style-type: decimal;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style li {
display: list-item;
text-align: -webkit-match-parent;
}
.default-style li::marker {
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
text-transform: none;
text-indent: 0px !important;
text-align: start !important;
text-align-last: start !important;
}
+8 -5
View File
@@ -8,25 +8,28 @@
<span class="material-icons text-3xl text-white">arrow_back</span> <span class="material-icons text-3xl text-white">arrow_back</span>
</a> </a>
<div v-if="user && currentLibrary"> <div v-if="user && currentLibrary">
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal"> <div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" /> <widgets-library-icon :icon="currentLibraryIcon" :size="4" />
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p> <p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div> </div>
</div> </div>
<widgets-connection-indicator />
<div class="flex-grow" /> <div class="flex-grow" />
<widgets-download-progress-indicator /> <widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server --> <!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5"> <div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span> <span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div> </div>
<nuxt-link v-if="user" class="h-7 mx-2" to="/search"> <nuxt-link v-if="user" class="h-7 mx-1.5" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span> <span class="material-icons" style="font-size: 1.75rem">search</span>
</nuxt-link> </nuxt-link>
<div class="h-7 mx-2"> <div class="h-7 mx-1.5">
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span> <span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
</div> </div>
</div> </div>
+103 -37
View File
@@ -30,13 +30,17 @@
</div> </div>
</div> </div>
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer"> <div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full"> <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"> <div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
<p class="title-text font-book truncate">{{ title }}</p> <p class="title-text font-book truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p> <p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
</div> </div>
@@ -63,12 +67,12 @@
<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" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span> <span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <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="forward10">forward_10</span> <span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span> <span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div> </div>
</div> </div>
@@ -109,6 +113,7 @@ export default {
}, },
data() { data() {
return { return {
windowHeight: 0,
playbackSession: null, playbackSession: null,
showChapterModal: false, showChapterModal: false,
showFullscreen: false, showFullscreen: false,
@@ -122,20 +127,28 @@ export default {
isEnded: false, isEnded: false,
volume: 0.5, volume: 0.5,
readyTrackWidth: 0, readyTrackWidth: 0,
playedTrackWidth: 0,
seekedTime: 0, seekedTime: 0,
seekLoading: false, seekLoading: false,
onPlaybackSessionListener: null, onPlaybackSessionListener: null,
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,
isLoading: false, isLoading: false,
touchTrackStart: false, touchTrackStart: false,
dragPercent: 0 dragPercent: 0,
syncStatus: 0
}
},
watch: {
showFullscreen(val) {
this.updateScreenSize()
this.$store.commit('setPlayerFullscreen', !!val)
} }
}, },
computed: { computed: {
@@ -153,14 +166,31 @@ export default {
}) })
return items return items
}, },
jumpForwardIcon() {
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
},
jumpBackwardsIcon() {
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
},
jumpForwardTime() {
return this.$store.getters['getJumpForwardTime']
},
jumpBackwardsTime() {
return this.$store.getters['getJumpBackwardsTime']
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['getBookCoverAspectRatio']
}, },
bookCoverWidth() { bookCoverWidth() {
if (this.showFullscreen) return this.fullscreenBookCoverWidth
return 60
},
fullscreenBookCoverWidth() {
var heightScale = (this.windowHeight - 200) / 651
if (this.bookCoverAspectRatio === 1) { if (this.bookCoverAspectRatio === 1) {
return this.showFullscreen ? 260 : 60 return 260 * heightScale
} }
return this.showFullscreen ? 200 : 60 return 200 * heightScale
}, },
showCastBtn() { showCastBtn() {
return this.$store.state.isCastAvailable return this.$store.state.isCastAvailable
@@ -271,6 +301,14 @@ export default {
} }
}, },
methods: { methods: {
clickTitleAndAuthor() {
if (!this.showFullscreen) return
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
if (llid) {
this.$router.push(`/item/${llid}`)
this.showFullscreen = false
}
},
touchstartTrack(e) { touchstartTrack(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
this.touchTrackStart = true this.touchTrackStart = true
@@ -331,13 +369,13 @@ export default {
restart() { restart() {
this.seek(0) this.seek(0)
}, },
backward10() { jumpBackwards() {
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: 10 }) AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
}, },
forward10() { jumpForward() {
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: 10 }) AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
}, },
setStreamReady() { setStreamReady() {
this.readyTrackWidth = this.trackWidth this.readyTrackWidth = this.trackWidth
@@ -416,15 +454,11 @@ export default {
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start var currChapTime = this.currentTime - this.currentChapter.start
percentDone = currChapTime / this.currentChapterDuration percentDone = currChapTime / this.currentChapterDuration
bufferedPercent = (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
} }
var ptWidth = Math.round(percentDone * this.trackWidth) var ptWidth = Math.round(percentDone * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
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'
this.playedTrackWidth = ptWidth
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'
@@ -447,7 +481,6 @@ export default {
var perc = time / this.totalDuration var perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth) var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200') this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300') this.$refs.playedTrack.classList.add('bg-yellow-300')
@@ -497,7 +530,6 @@ export default {
var data = await AbsAudioPlayer.getCurrentTime() var data = await AbsAudioPlayer.getCurrentTime()
this.currentTime = Number(data.value.toFixed(2)) this.currentTime = Number(data.value.toFixed(2))
this.bufferedTime = Number(data.bufferedTime.toFixed(2)) this.bufferedTime = Number(data.bufferedTime.toFixed(2))
console.log('[AudioPlayer] Got Current Time', this.currentTime)
this.timeupdate() this.timeupdate()
}, 1000) }, 1000)
}, },
@@ -650,6 +682,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
@@ -662,7 +695,6 @@ export default {
}) })
}, },
onPlaybackClosed() { onPlaybackClosed() {
console.log('Received onPlaybackClosed evt')
this.endPlayback() this.endPlayback()
}, },
onPlaybackFailed(data) { onPlaybackFailed(data) {
@@ -679,21 +711,53 @@ export default {
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() {
setTimeout(this.updateScreenSize, 50)
},
updateScreenSize() {
this.windowHeight = window.innerHeight
var coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
},
minimizePlayerEvt() {
console.log('Minimize Player Evt')
this.showFullscreen = false
},
showProgressSyncIsFailing() {
this.syncStatus = this.$constants.SyncStatus.FAILED
},
showProgressSyncSuccess() {
this.syncStatus = this.$constants.SyncStatus.SUCCESS
} }
}, },
mounted() { mounted() {
this.updateScreenSize()
if (screen.orientation) {
screen.orientation.addEventListener('change', this.screenOrientationChange)
}
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend) document.body.addEventListener('touchend', this.touchend)
document.body.addEventListener('touchmove', this.touchmove) document.body.addEventListener('touchmove', this.touchmove)
this.$nextTick(this.init) this.$nextTick(this.init)
}, },
beforeDestroy() { beforeDestroy() {
if (screen.orientation) {
screen.orientation.removeEventListener('change', this.screenOrientationChange)
}
if (this.playbackSession) { if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback') console.log('[AudioPlayer] Before destroy closing playback')
this.closePlayback() this.closePlayback()
} }
this.forceCloseDropdownMenu() this.forceCloseDropdownMenu()
this.$eventBus.$off('minimize-player', this.minimizePlayerEvt)
document.body.removeEventListener('touchstart', this.touchstart) document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove) document.body.removeEventListener('touchmove', this.touchmove)
@@ -703,12 +767,20 @@ 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)
} }
} }
</script> </script>
<style> <style>
:root {
--cover-image-width: 0px;
--cover-image-height: 0px;
--cover-image-width-collapsed: 60px;
--cover-image-height-collapsed: 60px;
}
.bookCoverWrapper { .bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066; box-shadow: 3px -2px 5px #00000066;
} }
@@ -739,15 +811,12 @@ export default {
.cover-wrapper { .cover-wrapper {
bottom: 44px; bottom: 44px;
left: 12px; left: 12px;
height: 96px; height: var(--cover-image-height-collapsed);
width: 60px; width: var(--cover-image-width-collapsed);
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height; transition-property: left, bottom, width, height;
transform-origin: left bottom; transform-origin: left bottom;
} }
.cover-wrapper.square-cover {
height: 60px;
}
.total-track { .total-track {
bottom: 215px; bottom: 215px;
@@ -779,16 +848,18 @@ export default {
} }
.fullscreen .title-author-texts { .fullscreen .title-author-texts {
bottom: 36%; bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
width: 80%; width: 80%;
left: 10%; left: 10%;
text-align: center; text-align: center;
padding-bottom: calc(((260px - var(--cover-image-height)) / 260) * 40);
pointer-events: auto;
} }
.fullscreen .title-author-texts .title-text { .fullscreen .title-author-texts .title-text {
font-size: 1.2rem; font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.2rem);
} }
.fullscreen .title-author-texts .author-text { .fullscreen .title-author-texts .author-text {
font-size: 1rem; font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
} }
#playerControls { #playerControls {
@@ -826,16 +897,11 @@ export default {
} }
.fullscreen .cover-wrapper { .fullscreen .cover-wrapper {
bottom: 46%;
left: calc(50% - 100px);
margin: 0 auto; margin: 0 auto;
height: 320px; height: var(--cover-image-height);
width: 200px; width: var(--cover-image-width);
} left: calc(50% - (calc(var(--cover-image-width)) / 2));
.fullscreen .cover-wrapper.square-cover { bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
height: 260px;
width: 260px;
left: calc(50% - 130px);
} }
.fullscreen #playerControls { .fullscreen #playerControls {
+29 -17
View File
@@ -30,11 +30,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: {
@@ -175,21 +174,27 @@ 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) => {
console.log('Library item play response', JSON.stringify(data)) if (data.error) {
AbsAudioPlayer.requestSession() const errorMsg = data.error || 'Failed to play'
this.$toast.error(errorMsg)
} else {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.$toast.error('Failed to play')
}) })
}, },
async playLibraryItem(payload) { async playLibraryItem(payload) {
@@ -199,6 +204,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({
@@ -211,6 +217,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) {
@@ -220,15 +227,26 @@ export default {
console.log('Called playLibraryItem', libraryItemId) console.log('Called playLibraryItem', libraryItemId)
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate }) AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
.then((data) => { .then((data) => {
console.log('Library item play response', JSON.stringify(data)) if (data.error) {
if (!libraryItemId.startsWith('local')) { const errorMsg = data.error || 'Failed to play'
this.serverLibraryItemId = libraryItemId this.$toast.error(errorMsg)
} else { } else {
this.serverLibraryItemId = serverLibraryItemId console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
} else {
this.serverLibraryItemId = serverLibraryItemId
}
if (episodeId && !episodeId.startsWith('local')) {
this.serverEpisodeId = episodeId
} else {
this.serverEpisodeId = serverEpisodeId
}
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.$toast.error('Failed to play')
}) })
}, },
pauseItem() { pauseItem() {
@@ -243,10 +261,6 @@ export default {
onMediaPlayerChanged(data) { onMediaPlayerChanged(data) {
var mediaPlayer = data.value var mediaPlayer = data.value
this.$store.commit('setMediaPlayer', mediaPlayer) this.$store.commit('setMediaPlayer', mediaPlayer)
},
showProgressSyncIsFailing() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced', { timeout: false, type: 'error' })
} }
}, },
mounted() { mounted() {
@@ -254,7 +268,6 @@ 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}`)
@@ -271,7 +284,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)
+21 -9
View File
@@ -19,7 +19,7 @@
</div> </div>
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300"> <div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center"> <div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
<p class="text-xs">{{ serverConnectionConfig.address }}</p> <p class="text-xs text-gray-400" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<p class="text-xs">{{ $config.version }}</p> <p class="text-xs">{{ $config.version }}</p>
@@ -71,6 +71,9 @@ export default {
serverConnectionConfig() { serverConnectionConfig() {
return this.$store.state.user.serverConnectionConfig return this.$store.state.user.serverConnectionConfig
}, },
serverSettings() {
return this.$store.state.serverSettings || {}
},
username() { username() {
return this.user ? this.user.username : '' return this.user ? this.user.username : ''
}, },
@@ -96,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') {
@@ -105,12 +113,12 @@ export default {
text: 'Local Media', text: 'Local Media',
to: '/localMedia/folders' to: '/localMedia/folders'
}) })
// items.push({
// icon: 'settings',
// text: 'Settings',
// to: '/settings'
// })
} }
items.push({
icon: 'settings',
text: 'Settings',
to: '/settings'
})
return items return items
}, },
currentRoutePath() { currentRoutePath() {
@@ -122,11 +130,15 @@ export default {
this.show = false this.show = false
}, },
async logout() { async logout() {
await this.$axios.$post('/logout').catch((error) => { if (this.user) {
console.error(error) await this.$axios.$post('/logout').catch((error) => {
}) console.error(error)
})
}
this.$socket.logout() this.$socket.logout()
await this.$db.logout() await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout') this.$store.commit('user/logout')
this.$router.push('/connect') this.$router.push('/connect')
}, },
+18 -4
View File
@@ -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: {
@@ -302,6 +313,9 @@ export default {
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2 this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2 this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
const entitiesPerPage = this.shelvesPerPage * this.entitiesPerShelf
this.booksPerFetch = Math.ceil(entitiesPerPage / 20) * 20 // Round up to the nearest 20
if (this.totalEntities) { if (this.totalEntities) {
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf) this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
} }
@@ -382,7 +396,7 @@ export default {
this.resetEntities() this.resetEntities()
} }
}, },
libraryChanged(libid) { libraryChanged() {
if (this.hasFilter) { if (this.hasFilter) {
this.clearFilter() this.clearFilter()
} else { } else {
+33 -9
View File
@@ -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: {},
+11 -6
View File
@@ -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 || '&nbsp;' }}</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>
@@ -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,
@@ -239,7 +239,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 +250,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 +346,11 @@ 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
} }
}, },
methods: { methods: {
+4 -3
View File
@@ -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 {
+3 -3
View File
@@ -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">&nbsp;#{{ seriesSequence }}</span> <span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</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'
} }
+6 -2
View File
@@ -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() {
+57 -20
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full max-w-md mx-auto px-4 sm:px-6 lg:px-8 z-10"> <div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 w-full"> <div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
<template v-if="!showForm"> <template v-if="!showForm">
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)"> <div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
<span class="material-icons-outlined text-xl text-gray-300">dns</span> <span class="material-icons-outlined text-xl text-gray-300">dns</span>
@@ -16,10 +16,18 @@
</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 sm:w-72 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"> <div class="flex justify-end items-center mt-6">
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10 mt-4">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn> <!-- <div class="relative flex">
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
</div> -->
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div> </div>
</form> </form>
<template v-if="showAuth"> <template v-if="showAuth">
@@ -62,6 +70,8 @@
</svg> </svg>
</div> </div>
</div> </div>
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
</div> </div>
</template> </template>
@@ -69,23 +79,26 @@
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
export default { export default {
props: {},
data() { data() {
return { return {
deviceData: null,
loggedIn: false, loggedIn: false,
showAuth: false, showAuth: false,
processing: false, processing: false,
serverConfig: { serverConfig: {
address: null, address: null,
username: null username: null,
customHeaders: null
}, },
password: null, password: null,
error: null, error: null,
showForm: false showForm: false,
showAddCustomHeaders: false
} }
}, },
computed: { computed: {
deviceData() {
return this.$store.state.deviceData || {}
},
networkConnected() { networkConnected() {
return this.$store.state.networkConnected return this.$store.state.networkConnected
}, },
@@ -98,9 +111,16 @@ export default {
lastServerConnectionConfig() { lastServerConnectionConfig() {
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId) return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
},
numCustomHeaders() {
if (!this.serverConfig.customHeaders) return 0
return Object.keys(this.serverConfig.customHeaders).length
} }
}, },
methods: { methods: {
addCustomHeaders() {
this.showAddCustomHeaders = true
},
showServerList() { showServerList() {
this.showForm = false this.showForm = false
this.showAuth = false this.showAuth = false
@@ -132,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
} }
@@ -147,7 +167,10 @@ export default {
if (value) { if (value) {
this.processing = true this.processing = true
await this.$db.removeServerConnectionConfig(this.serverConfig.id) await this.$db.removeServerConnectionConfig(this.serverConfig.id)
this.deviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id) const updatedDeviceData = { ...this.deviceData }
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
this.$store.commit('setDeviceData', updatedDeviceData)
this.serverConfig = { this.serverConfig = {
address: null, address: null,
userId: null, userId: null,
@@ -165,7 +188,6 @@ export default {
} }
this.showForm = true this.showForm = true
this.showAuth = true this.showAuth = true
console.log('Edit server config', serverConfig)
}, },
newServerConfigClick() { newServerConfigClick() {
this.serverConfig = { this.serverConfig = {
@@ -192,9 +214,13 @@ export default {
return null return null
} }
}, },
pingServerAddress(address) { pingServerAddress(address, customHeaders) {
const options = { timeout: 3000 }
if (customHeaders) {
options.headers = customHeaders
}
return this.$axios return this.$axios
.$get(`${address}/ping`, { timeout: 3000 }) .$get(`${address}/ping`, options)
.then((data) => data.success) .then((data) => data.success)
.catch((error) => { .catch((error) => {
console.error('Server check failed', error) console.error('Server check failed', error)
@@ -203,8 +229,12 @@ export default {
}) })
}, },
requestServerLogin() { requestServerLogin() {
const options = {}
if (this.serverConfig.customHeaders) {
options.headers = this.serverConfig.customHeaders
}
return this.$axios return this.$axios
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }) .$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
.then((data) => { .then((data) => {
if (!data.user) { if (!data.user) {
console.error(data.error) console.error(data.error)
@@ -236,7 +266,7 @@ export default {
this.processing = true this.processing = true
this.error = null this.error = null
var success = await this.pingServerAddress(this.serverConfig.address) var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
this.processing = false this.processing = false
if (success) this.showAuth = true if (success) this.showAuth = true
}, },
@@ -246,20 +276,29 @@ 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 && this.serverConfig.id !== scc.id)
if (duplicateConfig) {
this.error = 'Config already exists for this address and username'
return
}
this.error = null this.error = null
this.processing = true this.processing = true
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))) {
@@ -298,8 +337,6 @@ export default {
return authRes return authRes
}, },
async init() { async init() {
this.deviceData = await this.$db.getDeviceData()
if (this.lastServerConnectionConfig) { if (this.lastServerConnectionConfig) {
this.connectToServer(this.lastServerConnectionConfig) this.connectToServer(this.lastServerConnectionConfig)
} else { } else {
+1 -1
View File
@@ -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>
+96
View File
@@ -0,0 +1,96 @@
<template>
<modals-modal v-model="show" :width="'90%'" :max-width="'420px'" height="100%">
<template #outer>
<div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Custom Headers</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop>
<div class="w-full h-full p-4" v-if="showAddHeader">
<div class="mb-4">
<ui-icon-btn icon="arrow_back" borderless @click="showAddHeader = false" />
</div>
<form @submit.prevent="submitForm">
<ui-text-input-with-label v-model="newHeaderKey" label="Name" class="mb-2" />
<ui-text-input-with-label v-model="newHeaderValue" label="Value" class="mb-4" />
<ui-btn type="submit" class="w-full">Submit</ui-btn>
</form>
</div>
<div class="w-full h-full p-4" v-else>
<template v-for="[key, value] in Object.entries(headersCopy)">
<div :key="key" class="w-full rounded-lg bg-white bg-opacity-5 py-2 pl-4 pr-12 relative mb-2">
<p class="text-base font-semibold text-gray-200 leading-5">{{ key }}</p>
<p class="text-sm text-gray-400">{{ value }}</p>
<div class="absolute top-0 bottom-0 right-0 h-full p-4 flex items-center justify-center text-error">
<button @click="removeHeader(key)"><span class="material-icons text-lg">delete</span></button>
</div>
</div>
</template>
<p v-if="!Object.keys(headersCopy).length" class="py-4 text-center">No Custom Headers</p>
<div class="w-full flex justify-center pt-4">
<ui-btn @click="showAddHeader = true" class="w-full">Add Custom Header</ui-btn>
</div>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
customHeaders: {
type: Object,
default: () => {}
}
},
data() {
return {
newHeaderKey: '',
newHeaderValue: '',
headersCopy: {},
showAddHeader: false
}
},
watch: {
show(val) {
if (val) this.init()
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
removeHeader(key) {
this.$delete(this.headersCopy, key)
this.$emit('update:customHeaders', { ...this.headersCopy })
},
submitForm() {
console.log('Submit form', this.newHeaderKey, this.newHeaderValue)
this.headersCopy[this.newHeaderKey] = this.newHeaderValue
this.newHeaderKey = ''
this.newHeaderValue = ''
this.showAddHeader = false
this.$emit('update:customHeaders', { ...this.headersCopy })
},
init() {
this.newHeaderKey = ''
this.newHeaderValue = ''
this.headersCopy = this.customHeaders ? { ...this.customHeaders } : {}
}
},
mounted() {}
}
</script>
+8
View File
@@ -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']
}, },
+74
View File
@@ -0,0 +1,74 @@
<template>
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<covers-book-cover :library-item="libraryItem" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
width: 0
}
},
watch: {
show(val) {
if (val) {
this.setWidth()
this.setListeners()
} else {
this.removeListeners()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
}
},
methods: {
screenOrientationChange() {
setTimeout(this.setWidth, 50)
},
setListeners() {
screen.orientation.addEventListener('change', this.screenOrientationChange)
},
removeListeners() {
screen.orientation.removeEventListener('change', this.screenOrientationChange)
},
setWidth() {
if (window.innerHeight > window.innerWidth) {
this.width = window.innerWidth
} else {
this.width = window.innerHeight / this.bookCoverAspectRatio
}
}
},
mounted() {
this.setWidth()
}
}
</script>
<style>
.filter-modal-wrapper {
max-height: calc(100% - 320px);
}
</style>
+1 -1
View File
@@ -51,7 +51,7 @@ export default {
async clickedOption(lib) { async clickedOption(lib) {
this.show = false this.show = false
await this.$store.dispatch('libraries/fetch', lib.id) await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed', lib.id) this.$eventBus.$emit('library-changed')
this.$localStore.setLastLibraryId(lib.id) this.$localStore.setLastLibraryId(lib.id)
} }
}, },
+17 -1
View File
@@ -6,7 +6,7 @@
<span class="material-icons text-4xl">close</span> <span class="material-icons text-4xl">close</span>
</div> </div>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="max-width: 90%; min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg"> <div ref="content" style="min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
<slot /> <slot />
</div> </div>
</div> </div>
@@ -28,6 +28,10 @@ export default {
height: { height: {
type: [String, Number], type: [String, Number],
default: 'unset' default: 'unset'
},
maxWidth: {
type: String,
default: '90%'
} }
}, },
data() { data() {
@@ -73,6 +77,8 @@ export default {
} }
}, },
setShow() { setShow() {
this.$store.commit('globals/setIsModalOpen', true)
document.body.appendChild(this.el) document.body.appendChild(this.el)
setTimeout(() => { setTimeout(() => {
this.content.style.transform = 'scale(1)' this.content.style.transform = 'scale(1)'
@@ -80,18 +86,28 @@ export default {
document.documentElement.classList.add('modal-open') document.documentElement.classList.add('modal-open')
}, },
setHide() { setHide() {
this.$store.commit('globals/setIsModalOpen', false)
this.content.style.transform = 'scale(0)' this.content.style.transform = 'scale(0)'
this.el.remove() this.el.remove()
document.documentElement.classList.remove('modal-open') document.documentElement.classList.remove('modal-open')
},
closeModalEvt() {
console.log('Close modal event')
this.show = false
} }
}, },
mounted() { mounted() {
this.$eventBus.$on('close-modal', this.closeModalEvt)
this.el = this.$refs.wrapper this.el = this.$refs.wrapper
this.content = this.$refs.content this.content = this.$refs.content
this.content.style.transform = 'scale(0)' this.content.style.transform = 'scale(0)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)' this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.style.opacity = 1 this.el.style.opacity = 1
this.el.remove() this.el.remove()
},
beforeDestroy() {
this.$eventBus.$off('close-modal', this.closeModalEvt)
} }
} }
</script> </script>
+9 -7
View File
@@ -1,12 +1,12 @@
<template> <template>
<modals-modal v-model="show" :width="200" height="100%"> <modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
<template #outer> <template #outer>
<div class="absolute top-5 left-4 z-40"> <div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Playback Speed</p> <p class="text-white text-2xl truncate">Playback Speed</p>
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="closeMenu"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="rate in rates"> <template v-for="rate in rates">
@@ -91,15 +91,17 @@ export default {
var newPlaybackRate = this.selected - 0.1 var newPlaybackRate = this.selected - 0.1
this.selected = Number(newPlaybackRate.toFixed(1)) this.selected = Number(newPlaybackRate.toFixed(1))
}, },
closeMenu() { modalInput(val) {
if (this.currentPlaybackRate !== this.selected) { if (!val) {
this.$emit('change', this.selected) if (this.currentPlaybackRate !== this.selected) {
this.$emit('change', this.selected)
}
} }
this.show = false
}, },
clickedOption(rate) { clickedOption(rate) {
this.selected = Number(rate) this.selected = Number(rate)
this.$nextTick(this.closeMenu) this.show = false
this.$emit('change', Number(rate))
} }
}, },
mounted() {} mounted() {}
+219
View File
@@ -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>
+8 -6
View File
@@ -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: {
+14 -6
View File
@@ -1,13 +1,14 @@
<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 h-20"> <div v-if="book" 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="w-80 h-full px-2 flex items-center"> <div class="flex-grow book-table-content h-full px-2 flex items-center">
<div> <div class="max-w-full">
<nuxt-link :to="`/item/${book.id}`" class="truncate text-sm">{{ bookTitle }}</nuxt-link> <nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p> <p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -46,13 +47,13 @@ export default {
return this.mediaMetadata.authorName || '' return this.mediaMetadata.authorName || ''
}, },
bookDuration() { bookDuration() {
return this.$secondsToTimestamp(this.media.duration) return this.$elapsedPretty(this.media.duration)
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['getBookCoverAspectRatio']
}, },
bookWidth() { bookWidth() {
if (this.bookCoverAspectRatio === 1) return 80 if (this.bookCoverAspectRatio === 1) return 50
return 50 return 50
}, },
isMissing() { isMissing() {
@@ -79,3 +80,10 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style>
.book-table-content {
width: calc(100% - 50px);
max-width: calc(100% - 50px);
}
</style>
+5 -4
View File
@@ -16,9 +16,8 @@
<p class="text-sm font-semibold"> <p class="text-sm font-semibold">
{{ title }} {{ title }}
</p> </p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5"> <p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5 default-style" v-html="description" />
{{ description }}
</p>
<div class="flex items-center pt-2"> <div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick"> <div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span> <span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
@@ -200,7 +199,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', {
+1 -1
View File
@@ -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: [
{ {
@@ -0,0 +1,76 @@
<template>
<div v-if="icon" class="flex h-full items-center px-2">
<span class="material-icons-outlined text-lg" :class="iconClass" @click="showAlertDialog">{{ icon }}</span>
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
export default {
data() {
return {}
},
computed: {
user() {
return this.$store.state.user.user
},
socketConnected() {
return this.$store.state.socketConnected
},
networkConnected() {
return this.$store.state.networkConnected
},
networkConnectionType() {
return this.$store.state.networkConnectionType
},
isNetworkUnmetered() {
return this.$store.state.isNetworkUnmetered
},
isCellular() {
return this.networkConnectionType === 'cellular'
},
icon() {
if (!this.user) return null // hide when not connected to server
if (!this.networkConnected) {
return 'wifi_off'
} else if (!this.socketConnected) {
return 'cloud_off'
} else if (this.isCellular) {
return 'signal_cellular_alt'
} else {
return 'cloud_done'
}
},
iconClass() {
if (!this.networkConnected) return 'text-error'
else if (!this.socketConnected) return 'text-warning'
else if (!this.isNetworkUnmetered) return 'text-yellow-400'
else if (this.isCellular) return 'text-gray-200'
else return 'text-success'
}
},
methods: {
showAlertDialog() {
var msg = ''
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
if (!this.networkConnected) {
msg = 'No internet'
} else if (!this.socketConnected) {
msg = 'Socket not connected'
} else if (this.isCellular) {
msg = `Socket connected over ${meteredString} cellular`
} else {
msg = `Socket connected over ${meteredString} wifi`
}
Dialog.alert({
title: 'Connection Status',
message: msg
})
}
},
mounted() {},
beforeDestroy() {}
}
</script>
+8 -4
View File
@@ -27,6 +27,7 @@
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; }; 4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; }; 4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; }; 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
@@ -59,6 +60,7 @@
4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; }; 4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; };
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; }; 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; }; 4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -134,6 +136,7 @@
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */, 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
C4D0677428106D0C00B8F875 /* DataClasses.swift */, C4D0677428106D0C00B8F875 /* DataClasses.swift */,
3A90295E280968E700E1D427 /* PlaybackReport.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */,
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
); );
path = models; path = models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -325,6 +328,7 @@
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 */,
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */, 3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */, 3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */, C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
@@ -475,12 +479,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 = 9; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.47; MARKETING_VERSION = 0.9.56;
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)";
@@ -499,12 +503,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 = 9; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.47; MARKETING_VERSION = 0.9.56;
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 = "";
+15
View File
@@ -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,20 @@ 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: 1,
migrationBlock: { migration, oldSchemaVersion in
if (oldSchemaVersion < 1) {
NSLog("Realm schema version was \(oldSchemaVersion)")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["enableAltView"] = false
}
}
}
)
Realm.Configuration.defaultConfiguration = configuration
return true return true
} }
+12 -7
View File
@@ -23,6 +23,7 @@ public class AbsAudioPlayer: CAPPlugin {
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)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true; self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
} }
@objc func prepareLibraryItem(_ call: CAPPluginCall) { @objc func prepareLibraryItem(_ call: CAPPluginCall) {
@@ -43,10 +44,10 @@ public class AbsAudioPlayer: CAPPlugin {
initialPlayWhenReady = playWhenReady initialPlayWhenReady = playWhenReady
initialPlaybackRate = playbackRate initialPlaybackRate = playbackRate
PlayerHandler.stopPlayback()
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
do { do {
self.sendPlaybackSession(session: try session.asDictionary()) self.sendPlaybackSession(session: try session.asDictionary())
call.resolve(try session.asDictionary()) call.resolve(try session.asDictionary())
@@ -56,6 +57,8 @@ public class AbsAudioPlayer: CAPPlugin {
call.resolve([:]) call.resolve([:])
} }
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
self.sendMetadata() self.sendMetadata()
} }
} }
@@ -131,24 +134,26 @@ public class AbsAudioPlayer: CAPPlugin {
@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 timeSeconds = time / 1000
NSLog("chapter time: \(call.getBool("isChapterTime", false))") NSLog("chapter time: \(call.getBool("isChapterTime", false))")
if call.getBool("isChapterTime", false) { if call.getBool("isChapterTime", false) {
let timeToPause = time / 1000 - Int(PlayerHandler.getCurrentTime() ?? 0) let timeToPause = timeSeconds - Int(PlayerHandler.getCurrentTime() ?? 0)
if timeToPause < 0 { return call.resolve([ "success": false ]) } if timeToPause < 0 { return call.resolve([ "success": false ]) }
NSLog("oof \(timeToPause)") PlayerHandler.sleepTimerChapterStopTime = timeSeconds
PlayerHandler.remainingSleepTime = timeToPause PlayerHandler.remainingSleepTime = timeToPause
return call.resolve([ "success": true ]) return call.resolve([ "success": true ])
} }
PlayerHandler.remainingSleepTime = time / 1000 PlayerHandler.sleepTimerChapterStopTime = nil
PlayerHandler.remainingSleepTime = timeSeconds
call.resolve([ "success": true ]) call.resolve([ "success": true ])
} }
@objc func cancelSleepTimer(_ call: CAPPluginCall) { @objc func cancelSleepTimer(_ call: CAPPluginCall) {
PlayerHandler.remainingSleepTime = nil PlayerHandler.remainingSleepTime = nil
PlayerHandler.sleepTimerChapterStopTime = nil
call.resolve() call.resolve()
} }
@objc func getSleepTimerTime(_ call: CAPPluginCall) { @objc func getSleepTimerTime(_ call: CAPPluginCall) {
@@ -173,7 +178,7 @@ public class AbsAudioPlayer: CAPPlugin {
let playbackSession = PlayerHandler.getPlaybackSession() let playbackSession = PlayerHandler.getPlaybackSession()
let libraryItemId = playbackSession?.libraryItemId ?? "" let libraryItemId = playbackSession?.libraryItemId ?? ""
let episodeId = playbackSession?.episodeId ?? nil let episodeId = playbackSession?.episodeId ?? nil
NSLog("TEST: 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
+3 -1
View File
@@ -17,7 +17,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
CAP_PLUGIN_METHOD(getLocalLibraryItems, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItems, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItem, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItem, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemByLLId, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
) )
+22 -2
View File
@@ -65,11 +65,12 @@ public class AbsDatabase: CAPPlugin {
@objc func getDeviceData(_ call: CAPPluginCall) { @objc func getDeviceData(_ call: CAPPluginCall) {
let configs = Database.shared.getServerConnectionConfigs() let configs = Database.shared.getServerConnectionConfigs()
let index = Database.shared.getLastActiveConfigIndex() let index = Database.shared.getLastActiveConfigIndex()
let settings = Database.shared.getDeviceSettings()
call.resolve([ call.resolve([
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) }, "serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any, "lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
// "currentLocalPlaybackSession": nil, "deviceSettings": deviceSettingsToJSON(settings: settings)
]) ])
} }
@@ -79,10 +80,29 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItem(_ call: CAPPluginCall) { @objc func getLocalLibraryItem(_ call: CAPPluginCall) {
call.resolve() call.resolve()
} }
@objc func getLocalLibraryItemByLLId(_ call: CAPPluginCall) { @objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
call.resolve() call.resolve()
} }
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) { @objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
call.resolve([ "value": [] ]) call.resolve([ "value": [] ])
} }
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
call.resolve([ "value": [] ])
}
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
let enableAltView = call.getBool("enableAltView") ?? false
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
let settings = DeviceSettings()
settings.disableAutoRewind = disableAutoRewind
settings.enableAltView = enableAltView
settings.jumpBackwardsTime = jumpBackwardsTime
settings.jumpForwardTime = jumpForwardTime
Database.shared.setDeviceSettings(deviceSettings: settings)
// call.resolve([ "value": [] ])
getDeviceData(call)
}
} }
+6 -6
View File
@@ -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
@@ -0,0 +1,36 @@
//
// DeviceSettings.swift
// App
//
// Created by advplyr on 7/2/22.
//
import Foundation
import RealmSwift
class DeviceSettings: Object {
@Persisted var disableAutoRewind: Bool
@Persisted var enableAltView: Bool
@Persisted var jumpBackwardsTime: Int
@Persisted var jumpForwardTime: Int
}
func getDefaultDeviceSettings() -> DeviceSettings {
let settings = DeviceSettings()
settings.disableAutoRewind = false
settings.enableAltView = false
settings.jumpForwardTime = 10
settings.jumpBackwardsTime = 10
return settings
}
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
return Database.realmQueue.sync {
return [
"disableAutoRewind": settings.disableAutoRewind,
"enableAltView": settings.enableAltView,
"jumpBackwardsTime": settings.jumpBackwardsTime,
"jumpForwardTime": settings.jumpForwardTime
]
}
}
+11 -11
View File
@@ -66,10 +66,10 @@ class AudioPlayer: NSObject {
} }
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime) self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
NSLog("TEST: Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)") NSLog("Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count] let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
NSLog("TEST: Setting player items \(playerItems.count)") NSLog("Setting player items \(playerItems.count)")
for item in Array(playerItems) { for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last) self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
@@ -123,7 +123,7 @@ class AudioPlayer: NSObject {
self.audioPlayer.currentItem.map { item in self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0 self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) { if (self.currentTrackIndex != prevTrackIndex) {
NSLog("TEST: New Current track index \(self.currentTrackIndex)") NSLog("New Current track index \(self.currentTrackIndex)")
} }
} }
} }
@@ -133,7 +133,7 @@ class AudioPlayer: NSObject {
self.queueItemStatusObserver?.invalidate() self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
if (playerItem.status == .readyToPlay) { if (playerItem.status == .readyToPlay) {
NSLog("TEST: queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)") NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
self.updateNowPlaying() self.updateNowPlaying()
let firstReady = self.status < 0 let firstReady = self.status < 0
@@ -146,7 +146,7 @@ class AudioPlayer: NSObject {
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver") self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
} }
} else if (playerItem.status == .failed) { } else if (playerItem.status == .failed) {
NSLog("TEST: queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")") NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
} }
@@ -203,16 +203,16 @@ class AudioPlayer: NSObject {
pause() pause()
NSLog("TEST: Seek to \(to) from \(from)") NSLog("Seek to \(to) from \(from)")
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex] let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0 let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration let trackEnd = ctso + currentTrack.duration
NSLog("TEST: Seek current track END = \(trackEnd)") NSLog("Seek current track END = \(trackEnd)")
let indexOfSeek = getItemIndexForTime(time: to) let indexOfSeek = getItemIndexForTime(time: to)
NSLog("TEST: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)") NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
// Reconstruct queue if seeking to a different track // Reconstruct queue if seeking to a different track
if (self.currentTrackIndex != indexOfSeek) { if (self.currentTrackIndex != indexOfSeek) {
@@ -231,7 +231,7 @@ class AudioPlayer: NSObject {
setupQueueItemStatusObserver() setupQueueItemStatusObserver()
} else { } else {
NSLog("TEST: Seeking in current item \(to)") NSLog("Seeking in current item \(to)")
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = to - currentTrackStartOffset let seekTime = to - currentTrackStartOffset
@@ -250,7 +250,7 @@ class AudioPlayer: NSObject {
public func setPlaybackRate(_ rate: Float, observed: Bool = false) { public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
if self.audioPlayer.rate != rate { if self.audioPlayer.rate != rate {
NSLog("TEST: setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
self.audioPlayer.rate = rate self.audioPlayer.rate = rate
} }
if rate > 0.0 && !(observed && rate == 1) { if rate > 0.0 && !(observed && rate == 1) {
@@ -373,7 +373,7 @@ class AudioPlayer: NSObject {
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &playerContext { if context == &playerContext {
if keyPath == #keyPath(AVPlayer.rate) { if keyPath == #keyPath(AVPlayer.rate) {
NSLog("TEST: playerContext observer player rate") NSLog("playerContext observer player rate")
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true) self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
} else if keyPath == #keyPath(AVPlayer.currentItem) { } else if keyPath == #keyPath(AVPlayer.currentItem) {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
+30 -9
View File
@@ -11,7 +11,9 @@ class PlayerHandler {
private static var player: AudioPlayer? private static var player: AudioPlayer?
private static var session: PlaybackSession? private static var session: PlaybackSession?
private static var timer: Timer? private static var timer: Timer?
private static var lastSyncTime:Double = 0.0
public static var sleepTimerChapterStopTime: Int? = nil
private static var _remainingSleepTime: Int? = nil private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? { public static var remainingSleepTime: Int? {
get { get {
@@ -126,17 +128,27 @@ class PlayerHandler {
private static func tick() { private static func tick() {
if !paused { if !paused {
listeningTimePassedSinceLastSync += 1 listeningTimePassedSinceLastSync += 1
}
if listeningTimePassedSinceLastSync > 3 { if remainingSleepTime != nil {
syncProgress() if sleepTimerChapterStopTime != nil {
} let timeUntilChapterEnd = Double(sleepTimerChapterStopTime ?? 0) - (getCurrentTime() ?? 0)
if timeUntilChapterEnd <= 0 {
if remainingSleepTime != nil { paused = true
if remainingSleepTime! == 0 { remainingSleepTime = nil
paused = true } else {
remainingSleepTime = Int(timeUntilChapterEnd.rounded())
}
} else {
if remainingSleepTime! <= 0 {
paused = true
}
remainingSleepTime! -= 1
}
} }
remainingSleepTime! -= 1 }
if listeningTimePassedSinceLastSync >= 5 {
syncProgress()
} }
} }
public static func syncProgress() { public static func syncProgress() {
@@ -149,6 +161,15 @@ class PlayerHandler {
return return
} }
// Prevent multiple sync requests
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
if (lastSyncTime > 0 && timeSinceLastSync < 1) {
NSLog("syncProgress last sync time was < 1 second so not syncing")
return
}
lastSyncTime = Date().timeIntervalSince1970 // seconds
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync) let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = playerCurrentTime session!.currentTime = playerCurrentTime
+20
View File
@@ -128,4 +128,24 @@ class Database {
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
} }
} }
public func setDeviceSettings(deviceSettings: DeviceSettings) {
Database.realmQueue.sync {
let existing = instance.objects(DeviceSettings.self)
do {
try instance.write {
instance.delete(existing)
instance.add(deviceSettings)
}
} catch(let exception) {
NSLog("failed to save device settings")
debugPrint(exception)
}
}
}
public func getDeviceSettings() -> DeviceSettings {
return Database.realmQueue.sync {
return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
}
}
} }
+21 -19
View File
@@ -66,9 +66,6 @@ export default {
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
},
isSocketConnected() {
return this.$store.state.socketConnected
} }
}, },
methods: { methods: {
@@ -112,7 +109,7 @@ export default {
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`) console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
var authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` } }).catch((error) => { var authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` }, timeout: 3000 }).catch((error) => {
console.error('[Server] Server auth failed', error) console.error('[Server] Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg this.error = errorMsg
@@ -123,7 +120,8 @@ export default {
return return
} }
const { user, userDefaultLibraryId } = authRes const { user, userDefaultLibraryId, serverSettings } = authRes
this.$store.commit('setServerSettings', serverSettings)
// Set library - Use last library if set and available fallback to default user library // 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()
@@ -154,15 +152,9 @@ export default {
// Only cancels stream if streamining not playing downloaded // Only cancels stream if streamining not playing downloaded
this.$eventBus.$emit('close-stream') this.$eventBus.$emit('close-stream')
}, },
socketConnectionUpdate(isConnected) {
console.log('Socket connection update', isConnected)
},
socketConnectionFailed(err) { socketConnectionFailed(err) {
this.$toast.error('Socket connection error: ' + err.message) this.$toast.error('Socket connection error: ' + err.message)
}, },
socketInit(data) {
console.log('Socket init', data)
},
async initLibraries() { async initLibraries() {
if (this.inittingLibraries) { if (this.inittingLibraries) {
return return
@@ -170,13 +162,14 @@ export default {
this.inittingLibraries = true this.inittingLibraries = true
await this.$store.dispatch('libraries/load') await this.$store.dispatch('libraries/load')
console.log(`[default] initLibraries loaded ${this.currentLibraryId}`) console.log(`[default] initLibraries loaded ${this.currentLibraryId}`)
await this.$store.dispatch('libraries/fetch', this.currentLibraryId)
this.$eventBus.$emit('library-changed') this.$eventBus.$emit('library-changed')
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
this.inittingLibraries = false this.inittingLibraries = false
}, },
async syncLocalMediaProgress() { async syncLocalMediaProgress() {
if (!this.user) { if (!this.user) {
console.log('[default] No need to sync local media progress - not connected to server') console.log('[default] No need to sync local media progress - not connected to server')
this.$store.commit('setLastLocalMediaSyncResults', null)
return return
} }
@@ -184,10 +177,15 @@ export default {
var response = await this.$db.syncLocalMediaProgressWithServer() var response = await this.$db.syncLocalMediaProgressWithServer()
if (!response) { if (!response) {
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server') if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
this.$store.commit('setLastLocalMediaSyncResults', null)
return return
} }
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response
if (numLocalMediaProgressForServer > 0) { if (numLocalMediaProgressForServer > 0) {
response.syncedAt = Date.now()
response.serverConfigName = this.$store.getters['user/getServerConfigName']
this.$store.commit('setLastLocalMediaSyncResults', response)
if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) { if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) {
console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`) console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`)
} else { } else {
@@ -195,6 +193,7 @@ export default {
} }
} else { } else {
console.log('[default] syncLocalMediaProgress No local media progress to sync') console.log('[default] syncLocalMediaProgress No local media progress to sync')
this.$store.commit('setLastLocalMediaSyncResults', null)
} }
}, },
async userUpdated(user) { async userUpdated(user) {
@@ -216,9 +215,10 @@ export default {
mediaProgress: prog mediaProgress: prog
} }
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload) newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
} else { } else if (!localProg) {
// Check if local library item exists // Check if local library item exists
var localLibraryItem = await this.$db.getLocalLibraryItemByLLId(prog.libraryItemId) // local media progress may not exist yet if it hasn't been played
var localLibraryItem = await this.$db.getLocalLibraryItemByLId(prog.libraryItemId)
if (localLibraryItem) { if (localLibraryItem) {
if (prog.episodeId) { if (prog.episodeId) {
// If episode check if local episode exists // If episode check if local episode exists
@@ -253,15 +253,19 @@ export default {
} }
}, },
async mounted() { async mounted() {
this.$socket.on('connection-update', this.socketConnectionUpdate)
this.$socket.on('initialized', this.socketInit)
this.$socket.on('user_updated', this.userUpdated) this.$socket.on('user_updated', this.userUpdated)
this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated) this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated)
if (this.$store.state.isFirstLoad) { if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false) this.$store.commit('setIsFirstLoad', false)
const deviceData = await this.$db.getDeviceData()
this.$store.commit('setDeviceData', deviceData)
await this.$store.dispatch('setupNetworkListener') await this.$store.dispatch('setupNetworkListener')
await this.$store.dispatch('globals/loadLocalMediaProgress')
if (this.$store.state.user.serverConnectionConfig) { if (this.$store.state.user.serverConnectionConfig) {
console.log(`[default] server connection config set - call init libraries`) console.log(`[default] server connection config set - call init libraries`)
await this.initLibraries() await this.initLibraries()
@@ -272,14 +276,12 @@ export default {
console.log(`[default] finished connection attempt or already connected ${!!this.user}`) console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
await this.syncLocalMediaProgress() await this.syncLocalMediaProgress()
this.$store.dispatch('globals/loadLocalMediaProgress')
this.loadSavedSettings() this.loadSavedSettings()
this.hasMounted = true this.hasMounted = true
} }
}, },
beforeDestroy() { beforeDestroy() {
this.$socket.off('connection-update', this.socketConnectionUpdate)
this.$socket.off('initialized', this.socketInit)
this.$socket.off('user_updated', this.userUpdated) this.$socket.off('user_updated', this.userUpdated)
this.$socket.off('user_media_progress_updated', this.userMediaProgressUpdated) this.$socket.off('user_media_progress_updated', this.userMediaProgressUpdated)
} }
+3 -2
View File
@@ -45,7 +45,8 @@ export default {
index, index,
width: this.entityWidth, width: this.entityWidth,
height: this.entityHeight, height: this.entityHeight,
bookCoverAspectRatio: this.bookCoverAspectRatio bookCoverAspectRatio: this.bookCoverAspectRatio,
isAltViewEnabled: this.altViewEnabled
} }
if (this.entityName === 'series-books') props.showSequence = true if (this.entityName === 'series-books') props.showSequence = true
if (this.entityName === 'books') { if (this.entityName === 'books') {
@@ -54,7 +55,7 @@ export default {
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
} }
var _this = this // var _this = this
var instance = new ComponentClass({ var instance = new ComponentClass({
propsData: props, propsData: props,
created() { created() {
+3 -2
View File
@@ -1,11 +1,12 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.48-beta", "version": "0.9.56-beta",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.9.44-beta", "name": "audiobookshelf-app",
"version": "0.9.56-beta",
"dependencies": { "dependencies": {
"@capacitor/android": "^3.4.3", "@capacitor/android": "^3.4.3",
"@capacitor/app": "^1.1.1", "@capacitor/app": "^1.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.48-beta", "version": "0.9.56-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
+10 -4
View File
@@ -53,10 +53,16 @@ export default {
}, },
methods: { methods: {
async logout() { async logout() {
await this.$axios.$post('/logout').catch((error) => { if (this.user) {
console.error(error) await this.$axios.$post('/logout').catch((error) => {
}) console.error(error)
this.$server.logout() })
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$router.push('/connect') this.$router.push('/connect')
} }
}, },
-4
View File
@@ -4,10 +4,6 @@
<home-bookshelf-toolbar v-show="!isHome" /> <home-bookshelf-toolbar v-show="!isHome" />
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''"> <div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''">
<nuxt-child /> <nuxt-child />
<!-- <div v-if="isLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<ui-loading-indicator />
</div>-->
</div> </div>
</div> </div>
</template> </template>

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