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
advplyr 910b3a2a17 Version bump 0.9.48-beta 2022-06-05 09:55:22 -05:00
advplyr 916da91ccb Fix:Android auto check if server connection available and use first available server config, add ping api check with 3 second timeout #212 #167 2022-06-05 09:51:37 -05:00
advplyr d70a99254a Android version code bump 2022-06-04 17:20:14 -05:00
advplyr dcf5bb61a2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-06-04 17:12:14 -05:00
advplyr aa65bb10c1 Update:Android auto reset saved data if server config id changes 2022-06-04 17:12:10 -05:00
advplyr 158448f8a2 iOS version bump 0.9.47 2022-06-04 16:37:26 -05:00
advplyr f4be9b3e26 Update:Pass device info with play request 2022-06-04 16:36:49 -05:00
advplyr 15d68ca285 Version bump 0.9.47-beta 2022-06-04 15:11:49 -05:00
advplyr ad12e6a19d Add:Author, narrator and series links on item page #186 2022-06-04 10:19:31 -05:00
advplyr 1f1b2fe85a Add:Filter for podcast episodes 2022-06-03 19:46:43 -05:00
advplyr 480df58ce4 Fix:Check with server after pause of 1 minute or longer for updated media progress & show toast on client if progress sync is failing 2022-06-03 18:58:07 -05:00
advplyr 2decf532b2 Add:Not Finished progress filter 2022-06-02 18:20:52 -05:00
advplyr 3ba87419ae Update:Hide size on item landing page if not set 2022-06-02 18:14:29 -05:00
advplyr 1c78af37fa Update:Item page redesign with more details like narrator and chapters, decreasing cover size 2022-06-02 17:57:37 -05:00
advplyr 2b5373aedd Update:Rename Hard Delete 2022-06-02 17:02:30 -05:00
advplyr 99bf960b8a Add:Filter and sort for podcast episodes table, Update:Sync local media progress when media progress is updated on the server 2022-06-01 19:38:26 -05:00
advplyr c4aca22c28 Fix:Android auto reset server items if no longer connected to server #201 2022-05-29 18:43:53 -05:00
advplyr 58bd0e0cee Add:Click and drag player progress track #110 2022-05-29 18:13:25 -05:00
advplyr c1c56f8f52 Fix play request payload 2022-05-27 20:48:13 -05:00
advplyr f930ba1941 Update:Send android device info when opening playback sessions 2022-05-26 19:10:29 -05:00
advplyr 251116a5ce Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-05-24 09:08:28 -05:00
advplyr 06739c0401 Fix:Base64 encoding include URL_SAFE flag to prevent having / in local library item ids #192 2022-05-24 09:08:23 -05:00
126 changed files with 3872 additions and 1101 deletions
+9 -5
View File
@@ -21,7 +21,11 @@ kotlin {
}
android {
kotlinOptions {
buildFeatures {
viewBinding true
}
kotlinOptions {
freeCompilerArgs = ['-Xjvm-default=all']
}
compileSdkVersion rootProject.ext.compileSdkVersion
@@ -29,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 75
versionName "0.9.46-beta"
versionCode 87
versionName "0.9.56-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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'
// Simple Storage
implementation "com.anggrayudi:storage:0.13.0"
implementation "com.anggrayudi:storage:0.14.0"
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
// 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
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
+63 -52
View File
@@ -1,83 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="preferExternal"
package="com.audiobookshelf.app">
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app"
android:installLocation="preferExternal" >
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true">
android:usesCleartextTraffic="true" >
<!-- <receiver-->
<!-- android:name=".NewAppWidget"-->
<!-- android:exported="true" >-->
<!-- <intent-filter>-->
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
<!-- </intent-filter>-->
<!-- <meta-data-->
<!-- android:name="android.appwidget.provider"-->
<!-- android:resource="@xml/new_app_widget_info" />-->
<!-- </receiver>-->
<!--Used by Android Auto-->
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<!-- Used by Android Auto -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!-- Support for Cast -->
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.audiobookshelf.app.CastOptionsProvider" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name="com.audiobookshelf.app.MainActivity"
android:label="@string/title_activity_main"
android:exported="true"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask">
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Register URL scheme -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
<!-- Register URL scheme -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
</activity>
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service
android:exported="true"
android:enabled="true"
android:name=".player.PlayerNotificationService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<service
android:name=".player.PlayerNotificationService"
android:enabled="true"
android:exported="true" >
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -1,21 +1,24 @@
package com.audiobookshelf.app
import android.Manifest
import android.app.DownloadManager
import android.content.*
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.*
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.AbsDatabase
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity
import io.paperdb.Paper
class MainActivity : BridgeActivity() {
@@ -50,14 +53,11 @@ class MainActivity : BridgeActivity() {
// .build())
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
// Grant full storage access for testing
// var ss = SimpleStorage(this)
// ss.requestFullStorageAccess()
DbManager.initialize(applicationContext)
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL,
@@ -68,8 +68,6 @@ class MainActivity : BridgeActivity() {
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
Paper.init(applicationContext)
}
override fun onDestroy() {
@@ -94,9 +92,7 @@ class MainActivity : BridgeActivity() {
foregroundService = mLocalBinder.getService()
// Let NativeAudio know foreground service is ready and setup event listener
if (pluginCallback != null) {
pluginCallback()
}
pluginCallback()
}
}
@@ -140,9 +136,4 @@ class MainActivity : BridgeActivity() {
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
// override fun onUserInteraction() {
// super.onUserInteraction()
// Log.d(tag, "USER INTERACTION")
// }
}
@@ -0,0 +1,96 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
import android.util.Log
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.WidgetEventEmitter
import com.audiobookshelf.app.player.PlayerNotificationService
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class NewAppWidget : AppWidgetProvider() {
val tag = "NewAppWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
}
}
override fun onEnabled(context: Context) {
Log.w(tag, "onEnabled check context ${context.packageName}")
// Enter relevant functionality for when the first widget is created
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns:PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, NewAppWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
val cover = playbackSession?.getCoverUri()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
}
}
})
}
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -59,8 +59,6 @@ data class LibraryItem(
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build()
}
@@ -309,7 +307,6 @@ data class PodcastEpisode(
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
@@ -399,21 +396,33 @@ data class BookChapter(
var start:Double,
var end:Double,
var title:String?
)
) {
@get:JsonIgnore
val startMs get() = (start * 1000L).toLong()
@get:JsonIgnore
val endMs get() = (end * 1000L).toLong()
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgress(
class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
var progress:Double, // 0 to 1
progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
) : MediaProgressWrapper(isFinished, progress)
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
@JsonSubTypes(
JsonSubTypes.Type(MediaProgress::class),
JsonSubTypes.Type(LocalMediaProgress::class)
)
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double)
// Helper class
data class LibraryItemWithEpisode(
@@ -1,16 +1,27 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader
import io.paperdb.Paper
import org.json.JSONObject
import java.io.File
class DbManager {
val tag = "DbManager"
companion object {
var isDbInitialized = false
fun initialize(ctx: Context) {
if (isDbInitialized) return
Paper.init(ctx)
isDbInitialized = true
Log.i("DbManager", "Initialized Paper db")
}
}
fun getDeviceData(): DeviceData {
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null)
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
}
fun saveDeviceData(deviceData:DeviceData) {
Paper.book("device").write("data", deviceData)
@@ -34,7 +45,7 @@ class DbManager {
}
}
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? {
fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
}
@@ -147,6 +158,7 @@ class DbManager {
// Check local files
lli.localFiles = lli.localFiles.filter { localFile ->
val file = File(localFile.absolutePath)
if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
@@ -201,7 +213,11 @@ class DbManager {
val localLibraryItems = getLocalLibraryItems()
localMediaProgress.forEach {
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
if (matchingLLI == null) {
if (!it.id.startsWith("local")) {
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI == null) {
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI.isPodcast) {
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.util.*
data class ServerConnectionConfig(
var id:String,
@@ -13,13 +12,41 @@ data class ServerConnectionConfig(
var address:String,
var userId:String,
var username:String,
var token:String
var token:String,
var customHeaders:Map<String, String>?
)
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
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(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media
var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
var deviceSettings:DeviceSettings?
) {
@JsonIgnore
fun getLastServerConnectionConfig():ServerConnectionConfig? {
@@ -66,3 +93,20 @@ data class LocalFolder(
JsonSubTypes.Type(LocalLibraryItem::class)
)
open class LibraryItemWrapper()
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo(
var manufacturer:String,
var model:String,
var brand:String,
var sdkVersion:Int,
var clientVersion: String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PlayItemRequestPayload(
var mediaPlayer:String,
var forceDirectPlay:Boolean,
var forceTranscode:Boolean,
var deviceInfo:DeviceInfo
)
@@ -1,20 +1,16 @@
package com.audiobookshelf.app.data
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.NOTIFICATION_LARGE_ICON_SIZE
import com.bumptech.glide.Glide
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -102,7 +98,7 @@ data class LocalLibraryItem(
}
@JsonIgnore
fun getMediaMetadata(ctx: Context): MediaMetadataCompat {
fun getMediaMetadata(): MediaMetadataCompat {
val coverUri = getCoverUri()
return MediaMetadataCompat.Builder().apply {
@@ -111,8 +107,6 @@ data class LocalLibraryItem(
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
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)
}.build()
}
@@ -2,16 +2,17 @@ package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlin.math.roundToInt
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaProgress(
class LocalMediaProgress(
var id:String,
var localLibraryItemId:String,
var localEpisodeId:String?,
var duration:Double,
var progress:Double, // 0 to 1
progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?,
@@ -21,7 +22,10 @@ data class LocalMediaProgress(
var serverUserId:String?,
var libraryItemId:String?,
var episodeId:String?
) {
) : MediaProgressWrapper(isFinished, progress) {
@get:JsonIgnore
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@JsonIgnore
fun updateIsFinished(finished:Boolean) {
if (isFinished != finished) { // If finished changed then set progress
@@ -42,4 +46,15 @@ data class LocalMediaProgress(
isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null
}
@JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime
duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt
startedAt = serverMediaProgress.startedAt
}
}
@@ -62,18 +62,26 @@ class PlaybackSession(
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore
val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@JsonIgnore
fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) {
for (i in 0 until audioTracks.size) {
val track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
return i
}
}
return audioTracks.size - 1
}
@JsonIgnore
fun getChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
return chapters.find { time >= it.startMs && it.endMs > time}
}
@JsonIgnore
fun getCurrentTrackTimeMs():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
@@ -83,6 +91,7 @@ class PlaybackSession(
@JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long {
if (index < 0 || index >= audioTracks.size) return 0L
val currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong()
}
@@ -115,8 +124,6 @@ class PlaybackSession(
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, 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)
return metadataBuilder.build()
}
@@ -1,9 +1,12 @@
package com.audiobookshelf.app.device
import android.util.Log
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.data.DeviceData
import com.audiobookshelf.app.data.ServerConnectionConfig
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.player.PlayerNotificationService
interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService)
}
object DeviceManager {
const val tag = "DeviceManager"
@@ -19,11 +22,18 @@ object DeviceManager {
val isConnectedToServer get() = serverConnectionConfig != null
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
var widgetUpdater:WidgetEventEmitter? = null
init {
Log.d(tag, "Device Manager Singleton invoked")
}
fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), 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.readValue
import com.getcapacitor.JSObject
import org.json.JSONException
class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
@@ -105,7 +106,7 @@ class FolderScanner(var ctx: Context) {
var coverContentUrl:String? = null
var coverAbsolutePath:String? = null
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
@@ -157,11 +158,11 @@ class FolderScanner(var ctx: Context) {
audioTrackToAdd = existingAudioTrack
} else {
// Create new audio track
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
audioTrackToAdd = track
}
startOffset += audioProbeResult.duration
startOffset += audioProbeResult?.duration ?: 0.0
isNewOrUpdated = true
} else {
audioTrackToAdd = existingAudioTrack
@@ -248,7 +249,7 @@ class FolderScanner(var ctx: Context) {
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
@@ -289,7 +290,7 @@ class FolderScanner(var ctx: Context) {
val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
@@ -378,7 +379,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false
// Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach {
@@ -431,7 +432,7 @@ class FolderScanner(var ctx: Context) {
// Create new audio track
val lastTrack = existingAudioTracks.lastOrNull()
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true
@@ -463,12 +464,24 @@ class FolderScanner(var ctx: Context) {
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
}
fun probeAudioFile(absolutePath:String):AudioProbeResult {
var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
fun probeAudioFile(absolutePath:String):AudioProbeResult? {
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
return audioProbeResult
var probeObject:JSObject? = null
try {
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,21 +1,29 @@
package com.audiobookshelf.app.media
import android.bluetooth.BluetoothClass
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import com.getcapacitor.JSObject
import java.util.*
import io.paperdb.Paper
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.json.JSONException
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
var serverLibraryItems = listOf<LibraryItem>()
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
@@ -24,16 +32,54 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
var serverPodcastEpisodes = listOf<PodcastEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>()
var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null
var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
fun initializeAndroidAuto() {
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
Paper.init(ctx)
}
var userSettingsPlaybackRate:Float? = null
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
}
fun getSavedPlaybackRate():Float {
if (userSettingsPlaybackRate != null) {
return userSettingsPlaybackRate ?: 1f
}
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
if (sharedPrefs != null) {
val userSettingsPref = sharedPrefs.getString("userSettings", null)
if (userSettingsPref != null) {
try {
val userSettings = JSObject(userSettingsPref)
if (userSettings.has("playbackRate")) {
userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat()
return userSettingsPlaybackRate ?: 1f
}
} catch(je:JSONException) {
Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}")
}
}
}
return 1f
}
fun checkResetServerItems() {
// When opening android auto need to check if still connected to server
// and reset any server data already set
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
serverPodcastEpisodes = listOf()
serverLibraryCategories = listOf()
serverLibraries = listOf()
serverLibraryItems = mutableListOf()
selectedLibraryItems = mutableListOf()
selectedLibraryId = ""
}
}
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) {
cb(serverLibraryCategories)
@@ -46,14 +92,22 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(serverLibraryItems)
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(selectedLibraryItems)
} else {
apiHandler.getLibraryItems(libraryId) { libraryItems ->
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)
}
}
@@ -90,7 +144,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val children = podcast.episodes?.map { podcastEpisode ->
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())
}
@@ -105,7 +163,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
selectedPodcast = podcast
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())
}
@@ -125,6 +187,94 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
var successfulPing = false
suspendCoroutine<Boolean> { cont ->
apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it
cont.resume(it)
}
}
return successfulPing
}
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
suspendCoroutine<MutableList<MediaProgress>> { cont ->
apiHandler.authorize(config) {
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
mediaProgress = it
}
cont.resume(mediaProgress)
}
}
return mediaProgress
}
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
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) {
// Loop through available configs and check if can connect
for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) {
val result = checkServerConnection(config)
if (result) {
hasValidConn = true
DeviceManager.serverConnectionConfig = config
Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}")
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)
}
}
}
// TODO: Load currently listening category for local items
fun loadLocalCategory():List<LibraryCategory> {
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
@@ -146,33 +296,49 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val localCategories = loadLocalCategory()
cats.addAll(localCategories)
// Connected to server and has internet - load other cats
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) {
if (!DeviceManager.isConnectedToServer) {
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig()
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
}
// Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected ->
if (isConnected) {
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
loadLibraries { libraries ->
val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraries { libraries ->
if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request")
cb(cats) // Return download category only
} else {
val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(library.id) { libraryCategories ->
loadLibraryCategories(library.id) { libraryCategories ->
// Only using book or podcast library categories for now
libraryCategories.forEach {
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) {
// Log.d(tag, "Using library category ${it.id}")
cats.add(it)
// Only using book or podcast library categories for now
libraryCategories.forEach {
// Add items in continue listening to serverLibraryItems
if (it.id == "continue-listening") {
it.entities.forEach { libraryItemWrapper ->
val libraryItem = libraryItemWrapper as LibraryItem
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
}
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) {
// Log.d(tag, "Using library category ${it.id}")
cats.add(it)
}
}
cb(cats)
}
}
cb(cats)
}
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
}
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
}
}
@@ -213,16 +379,54 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) {
val localLibraryItem = libraryItemWrapper as LocalLibraryItem
cb(localLibraryItem.getPlaybackSession(episode))
} else {
val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "",false, mediaPlayer) {
cb(it)
}
}
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) {
cb(libraryItemWrapper.getPlaybackSession(episode))
} else {
val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
if (it == null) {
cb(null)
} else {
cb(it)
}
}
}
}
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 {
@@ -21,9 +21,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
private val glideOptions = RequestOptions()
.fallback(R.drawable.icon)
.diskCacheStrategy(DiskCacheStrategy.DATA)
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@@ -4,13 +4,9 @@ import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.AnyRes
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.Library
import com.audiobookshelf.app.data.LibraryCategory
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalLibraryItem
import com.audiobookshelf.app.data.*
class BrowseTree(
val context: Context,
@@ -86,7 +82,7 @@ class BrowseTree(
localBooksCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context)
children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
@@ -95,7 +91,7 @@ class BrowseTree(
localPodcastsCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context)
children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
@@ -88,7 +88,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var currentMediaItemIndex = 0
private var pendingMediaItemRemovalPosition:PositionInfo? = null
private var pendingSeekCount = 0
private var pendingSeekWindowIndex = 0
private var pendingSeekWindowIndex = C.INDEX_UNSET
private var pendingSeekPositionMs = 0L
init {
@@ -4,12 +4,12 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
data class MediaProgressSyncData(
var timeListened:Long, // seconds
@@ -19,11 +19,13 @@ data class MediaProgressSyncData(
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
private val tag = "MediaProgressSync"
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false
private var lastSyncTime:Long = 0
private var failedSyncs:Int = 0
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null
@@ -41,67 +43,163 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress = null
listeningTimerTask?.cancel()
lastSyncTime = 0L
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
} else {
return
}
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
currentLocalMediaProgress = null
}
listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis()
Log.d(tag, "start: init last sync time $lastSyncTime")
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) {
// Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
val currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
if (currentTime > 0) {
sync(shouldSyncServer, currentTime) {
Log.d(tag, "Sync complete")
}
}
}
}
}
}
fun stop() {
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
val currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
reset()
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 sync(currentTime:Double) {
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
fun pause(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle")
Log.d(tag, "pause: Last sync time $lastSyncTime")
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) { // Current time should always be > 0 on pause
sync(true, currentTime) {
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
cb()
}
} else {
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
failedSyncs = 0
cb()
}
}
fun syncFromServerProgress(mediaProgress: MediaProgress) {
currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
}
}
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
if (lastSyncTime <= 0) {
Log.e(tag, "Last sync time is not set $lastSyncTime")
return
}
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return cb()
}
val listeningTimeToAdd = diffSinceLastSync / 1000L
lastSyncTime = System.currentTimeMillis()
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData)
if (currentPlaybackSession?.progress?.isNaN() == true) {
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
return cb()
}
if (currentIsLocal) {
// Save local progress sync
currentPlaybackSession?.let {
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
lastSyncTime = System.currentTimeMillis()
// Local library item is linked to a server library item
if (!it.libraryItemId.isNullOrEmpty()) {
// Send sync to server also if connected to this server and local item belongs to this server
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) {
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
// Send sync to server also if connected to this server and local item belongs to this server
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
if (syncSuccess) {
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
}
cb()
}
} else {
cb()
}
}
} else {
} else if (shouldSyncServer) {
apiHandler.sendProgressSync(currentSessionId, syncData) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
if (it) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
lastSyncTime = System.currentTimeMillis()
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
}
cb()
}
} else {
cb()
}
}
@@ -117,18 +215,22 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
currentLocalMediaProgress?.let {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%")
if (it.progress.isNaN()) {
Log.e(tag, "Invalid progress on local media progress")
} else {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
}
}
}
fun reset() {
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
currentPlaybackSession = null
currentLocalMediaProgress = null
lastSyncTime = 0L
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
}
}
@@ -7,14 +7,12 @@ import android.os.Handler
import android.os.Looper
import android.os.Message
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import kotlin.concurrent.schedule
@@ -28,10 +26,14 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true, playbackRate)
}
}
}
}
@@ -50,10 +52,14 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
}
}
@@ -101,13 +107,21 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
podcastEpisode = libraryItemWithEpisode?.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
}
}
libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
}
}
@@ -118,11 +132,31 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
}
fun handleCallMediaButton(intent: Intent): Boolean {
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
when (keyEvent?.getKeyCode()) {
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
if (playerNotificationService.mPlayer.isPlaying) {
playerNotificationService.pause()
} else {
playerNotificationService.play()
}
}
}
}
if (keyEvent?.action == KeyEvent.ACTION_UP) {
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_HEADSETHOOK -> {
Log.d(tag, "handleCallMediaButton: Headset Hook")
if (0 == mediaButtonClickCount) {
if (playerNotificationService.mPlayer.isPlaying)
playerNotificationService.pause()
@@ -132,6 +166,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PLAY -> {
Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
@@ -139,6 +174,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Pause")
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
}
@@ -152,6 +188,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
playerNotificationService.closePlayback()
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
@@ -164,7 +201,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
}
}
else -> {
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}")
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
return false
}
}
@@ -173,7 +210,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return true
}
fun handleMediaButtonClickCount() {
private fun handleMediaButtonClickCount() {
mediaButtonClickCount++
if (1 == mediaButtonClickCount) {
Timer().schedule(mediaButtonClickTimeout) {
@@ -199,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) {
//
// }
// }
}
@@ -30,9 +30,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepare(playWhenReady: Boolean) {
Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
@@ -53,10 +58,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
}
libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
@@ -65,10 +74,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
@@ -2,9 +2,12 @@ package com.audiobookshelf.app.player
import android.util.Log
import com.audiobookshelf.app.data.PlayerState
import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener"
@@ -21,7 +24,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
}
override fun onEvents(player: Player, events: Player.Events) {
Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
@@ -67,20 +70,37 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (player.isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0) {
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
var seekBackTime = 0L
if (onSeekBack) onSeekBack = false
else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var backTime = calcPauseSeekBackTime()
if (backTime > 0) {
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500
Log.d(tag, "SeekBackTime $backTime")
seekBackTime = calcPauseSeekBackTime()
if (seekBackTime > 0) {
// Current chapter is used so that seek back does not go back to the previous chapter
val currentChapter = playerNotificationService.getCurrentBookChapter()
val minSeekBackTime = currentChapter?.startMs ?: 0
val currentTime = playerNotificationService.getCurrentTime()
val newTime = currentTime - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
onSeekBack = true
playerNotificationService.seekBackward(backTime)
} else {
Log.d(tag, "SeekBackTime: back time is 0")
}
}
// Check if playback session still exists or sync media progress if updated
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
if (!shouldCarryOn) return
}
if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime)
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
@@ -88,24 +108,28 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
}
// Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
if (player.isPlaying) {
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.start()
} else {
playerNotificationService.mediaProgressSyncer.stop()
playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
}
}
private fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long
if (time < 3000) seekback = 0
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s
val time: Long = System.currentTimeMillis() - lastPauseTime
val seekback: Long
if (time < 10000) seekback = 0 // 10s or less = no seekback
else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s
else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
else seekback = 29500 // 30m and up = jump back 30s
return seekback
@@ -14,6 +14,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
PlayerNotificationService.isClosed = false
playerNotificationService.startForeground(notificationId, notification)
}
@@ -26,6 +27,17 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
playerNotificationService.stopSelf()
} else {
Log.d(tag, "onNotificationCancelled not dismissed by user")
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
if (!PlayerNotificationService.isClosed && !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.hardware.Sensor
import android.hardware.SensorManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.*
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
@@ -15,9 +19,14 @@ import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler
@@ -39,6 +48,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
companion object {
var isStarted = false
var isClosed = false
var isUnmeteredNetwork = false
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
}
interface ClientEventEmitter {
@@ -46,12 +58,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: PlaybackMetadata)
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
fun onPlaybackFailed(errorMessage:String)
fun onMediaPlayerChanged(mediaPlayer:String)
fun onProgressSyncFailing()
fun onProgressSyncSuccess()
fun onNetworkMeteredChanged(isUnmetered:Boolean)
}
private val tag = "PlayerService"
@@ -62,11 +76,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var ctx:Context
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat
lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager
private lateinit var apiHandler: ApiHandler
lateinit var apiHandler: ApiHandler
lateinit var mPlayer: ExoPlayer
lateinit var currentPlayer:Player
@@ -110,11 +124,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun getService(): PlayerNotificationService = this@PlayerNotificationService
}
fun stopService(context: Context) {
val stopIntent = Intent(context, PlayerNotificationService::class.java)
context.stopService(stopIntent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isStarted = true
Log.d(tag, "onStartCommand $startId")
@@ -139,6 +148,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// detach player
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)
mPlayer.release()
castPlayer?.release()
@@ -157,30 +174,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
stopSelf()
}
override fun onCreate() {
Log.d(tag, "onCreate")
super.onCreate()
ctx = this
// Initialize player
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start
1000 * 20 // 20s playback rebuffer
).build()
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(10000)
.setSeekForwardIncrementMs(10000)
// To listen for network change from metered to unmetered
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
currentPlayer = mPlayer
DbManager.initialize(ctx)
// Initialize API
apiHandler = ApiHandler(ctx)
@@ -211,6 +219,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
}
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@@ -232,6 +241,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true)
@@ -245,15 +255,24 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
if (currentPlaybackSession == null) {
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
return MediaDescriptionCompat.Builder().build()
}
val coverUri = currentPlaybackSession!!.getCoverUri()
// Fix for local images crashing on Android 11 for specific devices
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
ctx.grantUriPermission(
"com.android.systemui",
coverUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
ctx.grantUriPermission(
"com.android.systemui",
coverUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch(error:Exception) {
Log.e(tag, "Grant uri permission error $error")
}
return MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id)
@@ -274,17 +293,64 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
)
mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
mediaSessionConnector.setPlayer(mPlayer)
// Example adding custom action with icon in android auto
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider {
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
// }
// override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
// var icon = R.drawable.exo_icon_rewind
// return PlaybackStateCompat.CustomAction.Builder(
// "com.audiobookshelf.app.PLAYBACK_RATE", "Playback Rate", icon)
// .build()
// }
// })
mediaSession.setCallback(MediaSessionCallback(this))
initializeMPlayer()
currentPlayer = mPlayer
}
private fun initializeMPlayer() {
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start
1000 * 20 // 20s playback rebuffer
).build()
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(seekBackTime)
.setSeekForwardIncrementMs(seekForwardTime)
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
//attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer)
mediaSession.setCallback(MediaSessionCallback(this))
mediaSessionConnector.setPlayer(mPlayer)
}
/*
User callable methods
*/
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
if (!isStarted) {
Log.i(tag, "preparePlayer: foreground service not started - Starting service --")
Intent(ctx, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(ctx, intent)
}
}
isClosed = false
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
@@ -358,7 +424,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer.setPlaybackSpeed(playbackRateToUse)
currentPlayer.prepare()
} else if (castPlayer != null) {
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
@@ -373,19 +438,43 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession ->
if (playbackSession.isDirectPlay) {
val mediaPlayer = getMediaPlayer()
Log.d(tag, "Fallback to transcode $mediaPlayer")
val playItemRequestPayload = getPlayItemRequestPayload(true)
Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer")
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) { // Play request failed
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback(true)
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
} else {
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback()
closePlayback(true)
}
}
}
fun startNewPlaybackSession() {
currentPlaybackSession?.let { playbackSession ->
val forceTranscode = playbackSession.isHLS // If already HLS then force
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
Log.e(tag, "Failed to start new playback session")
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
}
}
@@ -410,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) {
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer)
@@ -424,15 +525,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
if (currentPlaybackSession == null) {
Log.d(tag, "switchToPlayer: No Current playback session")
}
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
clientEventEmitter?.onPlayingUpdate(false)
}
// TODO: Start a new playback session here instead of using the existing
preparePlayer(it, false, null)
}
}
@@ -469,10 +568,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.totalDurationMs ?: 0L
}
fun getCurrentBookTitle() : String? {
return currentPlaybackSession?.displayTitle
}
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
return currentPlaybackSession?.clone()
}
@@ -481,16 +576,102 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.id
}
fun getCurrentBookChapter():BookChapter? {
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
}
// Called from PlayerListener play event
// check with server if progress has updated since last play and sync progress update
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
if (currentPlaybackSession == null) return true
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
return true // carry on
}
if (playbackSession.isLocal) {
// Make sure this connection config exists
val serverConnectionConfig = DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId)
if (serverConnectionConfig == null) {
Log.d(tag, "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}")
return true // carry on
}
// Local playback session check if server has updated media progress
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId, serverConnectionConfig) { mediaProgress ->
if (mediaProgress != null && mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
// Update current playback session stored in PNS since MediaProgressSyncer version is a copy
mediaProgressSyncer.currentPlaybackSession?.let { updatedPlaybackSession ->
currentPlaybackSession = updatedPlaybackSession
}
Handler(Looper.getMainLooper()).post {
seekPlayer(playbackSession.currentTimeMs)
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
} else {
Handler(Looper.getMainLooper()).post {
if (seekBackTime > 0L) {
seekBackward(seekBackTime)
}
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
}
} else {
// Streaming from server so check if playback session still exists on server
Log.d(
tag,
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
)
apiHandler.getPlaybackSession(playbackSession.id) {
if (it == null) {
Log.d(
tag,
"checkCurrentSessionProgress: Playback session does not exist on server - start new playback session"
)
Handler(Looper.getMainLooper()).post {
currentPlayer.pause()
startNewPlaybackSession()
}
} else {
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
Handler(Looper.getMainLooper()).post {
if (seekBackTime > 0L) {
seekBackward(seekBackTime)
}
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
}
}
}
return false
}
fun play() {
if (currentPlayer.isPlaying) {
Log.d(tag, "Already playing")
return
}
currentPlayer.volume = 1F
if (currentPlayer == castPlayer) {
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
}
currentPlayer.play()
}
@@ -532,12 +713,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer.setPlaybackSpeed(speed)
}
fun closePlayback() {
currentPlayer.clearMediaItems()
currentPlayer.stop()
fun closePlayback(calledOnError:Boolean? = false) {
Log.d(tag, "closePlayback")
if (mediaProgressSyncer.listeningTimerRunning) {
Log.i(tag, "About to close playback so stopping media progress syncer first")
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
Log.d(tag, "Media Progress syncer stopped")
}
}
try {
currentPlayer.stop()
currentPlayer.clearMediaItems()
} catch(e:Exception) {
Log.e(tag, "Exception clearing exoplayer $e")
}
currentPlaybackSession = null
clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0
isClosed = true
stopForeground(true)
stopSelf()
}
fun sendClientMetadata(playerState: PlayerState) {
@@ -549,10 +746,33 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
}
fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE
manufacturer: Google
model: Pixel 6
brand: google
sdkVersion: 32
appVersion: 0.9.46-beta
*/
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
}
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
}
fun getContext():Context {
return ctx
}
fun alertSyncFailing() {
clientEventEmitter?.onProgressSyncFailing()
}
fun alertSyncSuccess() {
clientEventEmitter?.onProgressSyncSuccess()
}
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
@@ -561,7 +781,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
private val VALID_MEDIA_BROWSERS = mutableListOf<String>(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
@@ -586,11 +806,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// No further calls will be made to other media browsing methods.
null
} else {
if (!isStarted) {
Log.d(tag, "AA Not yet started")
mediaManager.initializeAndroidAuto()
isStarted = true
}
isStarted = true
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
isAndroidAuto = true
@@ -611,7 +828,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
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()
@@ -627,10 +844,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
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>?)
}
@@ -641,26 +860,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
val mediaMetadata = localLibraryItem.getMediaMetadata()
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 ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
val mediaMetadata = localLibraryItem.getMediaMetadata()
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(localBrowseItems)
} else { // Load categories
mediaManager.loadAndroidAutoItems() { libraryCategories ->
mediaManager.loadAndroidAutoItems { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item ->
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>?)
}
@@ -725,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.os.*
import android.util.Log
import com.audiobookshelf.app.device.DeviceManager
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
@@ -203,8 +204,13 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
}
fun handleShake() {
Log.d(tag, "HANDLE SHAKE HERE")
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
Log.d(tag, "Shake to reset sleep timer is disabled")
return
}
checkShouldExtendSleepTimer()
}
}
fun increaseSleepTime(time: Long) {
@@ -1,10 +1,8 @@
package com.audiobookshelf.app.plugins
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
@@ -59,13 +57,6 @@ class AbsAudioPlayer : Plugin() {
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
}
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
val jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("playWhenReady", playWhenReady)
notifyListeners("onPrepareMedia", jsobj)
}
override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition)
}
@@ -85,6 +76,18 @@ class AbsAudioPlayer : Plugin() {
override fun onMediaPlayerChanged(mediaPlayer:String) {
emit("onMediaPlayerChanged", mediaPlayer)
}
override fun onProgressSyncFailing() {
emit("onProgressSyncFailing", "")
}
override fun onProgressSyncSuccess() {
emit("onProgressSyncSuccess", "")
}
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
emit("onNetworkMeteredChanged", isUnmetered)
}
})
}
mainActivity.pluginCallback = foregroundServiceReady
@@ -153,14 +156,6 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod
fun prepareLibraryItem(call: PluginCall) {
// Need to make sure the player service has been started
if (!PlayerNotificationService.isStarted) {
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(mainActivity, intent)
}
}
val libraryItemId = call.getString("libraryItemId", "").toString()
val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true
@@ -168,7 +163,7 @@ class AbsAudioPlayer : Plugin() {
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
return call.resolve()
return call.resolve(JSObject("{\"error\":\"Invalid request\"}"))
}
if (libraryItemId.startsWith("local")) { // Play local media item
@@ -179,28 +174,56 @@ class AbsAudioPlayer : Plugin() {
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
if (episode == null) {
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
return call.resolve(JSObject())
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
}
}
Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
val playbackSession = it.getPlaybackSession(episode)
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(
playbackSession,
playWhenReady,
playbackRate
)
}
}
} else {
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
}
}
return call.resolve(JSObject())
}
} else { // Play library item from server
val mediaPlayer = playerNotificationService.getMediaPlayer()
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
} else {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
}
@@ -6,6 +6,7 @@ import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
@@ -24,6 +25,7 @@ class AbsDatabase : Plugin() {
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>)
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
override fun load() {
mainActivity = (activity as MainActivity)
@@ -36,7 +38,7 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getDeviceData(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var deviceData = DeviceManager.dbManager.getDeviceData()
val deviceData = DeviceManager.dbManager.getDeviceData()
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
}
}
@@ -44,17 +46,17 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getLocalFolders(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var folders = DeviceManager.dbManager.getAllLocalFolders()
val folders = DeviceManager.dbManager.getAllLocalFolders()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
}
}
@PluginMethod
fun getLocalFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
var folderObj = jacksonMapper.writeValueAsString(it)
val folderObj = jacksonMapper.writeValueAsString(it)
call.resolve(JSObject(folderObj))
} ?: call.resolve()
}
@@ -62,10 +64,10 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getLocalLibraryItem(call:PluginCall) {
var id = call.getString("id", "").toString()
val id = call.getString("id", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
if (localLibraryItem == null) {
call.resolve()
} else {
@@ -75,10 +77,10 @@ class AbsDatabase : Plugin() {
}
@PluginMethod
fun getLocalLibraryItemByLLId(call:PluginCall) {
var libraryItemId = call.getString("libraryItemId", "").toString()
fun getLocalLibraryItemByLId(call:PluginCall) {
val libraryItemId = call.getString("libraryItemId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId)
if (localLibraryItem == null) {
call.resolve()
} else {
@@ -89,40 +91,41 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getLocalLibraryItems(call:PluginCall) {
var mediaType = call.getString("mediaType", "").toString()
val mediaType = call.getString("mediaType", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun getLocalLibraryItemsInFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) {
var serverConnectionConfigId = call.getString("id", "").toString()
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
var userId = call.getString("userId", "").toString()
var username = call.getString("username", "").toString()
var token = call.getString("token", "").toString()
val userId = serverConfigPayload.userId
val username = serverConfigPayload.username
val token = serverConfigPayload.token
GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection
var serverAddress = call.getString("address", "").toString()
val serverAddress = call.getString("address", "").toString()
// Create new server connection config
var sscId = DeviceManager.getBase64Id("$serverAddress@$username")
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token)
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
// Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
@@ -139,8 +142,8 @@ class AbsDatabase : Plugin() {
}
// Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
shouldSave = true
}
@@ -155,7 +158,7 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null
@@ -181,14 +184,14 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getAllLocalMediaProgress(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
}
}
@PluginMethod
fun removeLocalMediaProgress(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve()
}
@@ -204,6 +207,63 @@ class AbsDatabase : Plugin() {
}
}
// Updates received via web socket
// This function doesn't need to sync with the server also because this data is coming from the server
// If sending the localMediaProgressId then update existing media progress
// If sending localLibraryItemId then save new local media progress
@PluginMethod
fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) {
val serverMediaProgress = call.getObject("mediaProgress").toString()
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
var localMediaProgressId = call.getString("localMediaProgressId") ?: ""
val mediaProgress = jacksonMapper.readValue<MediaProgress>(serverMediaProgress)
if (localMediaProgressId == "") {
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem != null) {
localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = mediaProgress.episodeId)
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
} else {
Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found")
}
} else {
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId")
val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
call.resolve()
} else {
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
}
}
@PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
@@ -273,13 +333,13 @@ class AbsDatabase : Plugin() {
// Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject()
val libraryItemId = localMediaProgress.libraryItemId ?: ""
val episodeId = localMediaProgress.episodeId ?: ""
val updatePayload = JSObject()
updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject()
val jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -288,7 +348,7 @@ class AbsDatabase : Plugin() {
}
}
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
var jsobj = JSObject()
val jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -298,25 +358,25 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun updateLocalTrackOrder(call:PluginCall) {
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
call.resolve()
return
}
var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
var tracks:JSArray = call.getArray("tracks") ?: JSArray()
val tracks:JSArray = call.getArray("tracks") ?: JSArray()
Log.d(tag, "updateLocalTrackOrder $tracks")
var index = 1
var hasUpdates = false
for (i in 0 until tracks.length()) {
var track = tracks.getJSONObject(i)
var localFileId = track.getString("localFileId")
val track = tracks.getJSONObject(i)
val localFileId = track.getString("localFileId")
var existingTrack = audioTracks.find{ it.localFileId == localFileId }
val existingTrack = audioTracks.find{ it.localFileId == localFileId }
if (existingTrack != null) {
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
if (existingTrack.index != index) hasUpdates = true
@@ -336,4 +396,15 @@ class AbsDatabase : Plugin() {
call.resolve()
}
}
@PluginMethod
fun updateDeviceSettings(call:PluginCall) { // Returns device data
Log.d(tag, "updateDeviceSettings ${call.data}")
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.deviceData.deviceSettings = newDeviceSettings
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
}
}
}
@@ -143,31 +143,35 @@ class AbsDownloader : Plugin() {
}
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
Log.d(tag, "Got library item from server ${libraryItem.id}")
if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
}
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
}
}
@@ -397,9 +401,13 @@ class AbsDownloader : Plugin() {
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb)
if (localFolderFile == null) {
Log.e(tag, "Local Folder File from uri is null")
} else {
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
file?.moveFileTo(mainActivity,localFolderFile,fileDescription,fcb)
}
} else {
// Why is kotlin requiring an else here..
}
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.plugins
import android.app.AlertDialog
import android.database.Cursor
import android.net.Uri
import android.os.Build
@@ -65,39 +66,59 @@ class AbsFileSystem : Plugin() {
@PluginMethod
fun selectFolder(call: PluginCall) {
var mediaType = call.data.getString("mediaType", "book").toString()
val mediaType = call.data.getString("mediaType", "book").toString()
val REQUEST_CODE_SELECT_FOLDER = 6
val REQUEST_CODE_SDCARD_ACCESS = 7
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity)
var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity)
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
val absolutePath = folder.getAbsolutePath(activity)
val storageType = folder.getStorageType(activity)
val simplePath = folder.getSimplePath(activity)
val basePath = folder.getBasePath(activity)
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED")
var jsobj = JSObject()
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
val jsobj = JSObject()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(
"You have no write access to this storage, thus selecting this folder is useless." +
"\nWould you like to grant access to this folder?")
builder.setNegativeButton("Dont Allow") { _, _ ->
run {
jsobj.put("error", "User Canceled, Access Denied")
call.resolve(jsobj)
}
}
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, storageType) }
builder.show()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject()
val jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(6)
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
}
@RequiresApi(Build.VERSION_CODES.R)
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.server
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
@@ -17,36 +16,43 @@ import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiHandler(var ctx:Context) {
val tag = "ApiHandler"
private var client = OkHttpClient()
private var defaultClient = OkHttpClient()
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var storageSharedPreferences: SharedPreferences? = null
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) {
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
val request = Request.Builder()
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
.build()
makeRequest(request, 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 requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().post(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
.build()
makeRequest(request, cb)
makeRequest(request, null, cb)
}
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
@@ -55,7 +61,7 @@ class ApiHandler(var ctx:Context) {
val request = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build()
makeRequest(request, cb)
makeRequest(request, null, cb)
}
fun isOnline(): Boolean {
@@ -76,30 +82,52 @@ class ApiHandler(var ctx:Context) {
return false
}
fun makeRequest(request:Request, cb: (JSObject) -> Unit) {
fun isUsingCellularData(): Boolean {
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
}
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
val client = httpClient ?: defaultClient
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb(JSObject())
val jsobj = JSObject()
jsobj.put("error", "Failed to connect")
cb(jsobj)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) throw IOException("Unexpected code $response")
if (!it.isSuccessful) {
val jsobj = JSObject()
jsobj.put("error", "Unexpected code $response")
cb(jsobj)
return
}
val bodyString = it.body!!.string()
val bodyString = it.body!!.string()
if (bodyString == "OK") {
cb(JSObject())
} else {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
try {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
}
cb(jsonObj)
} catch(je:JSONException) {
Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString")
val jsobj = JSObject()
jsobj.put("error", "Invalid response body")
cb(jsobj)
}
cb(jsonObj)
}
}
}
@@ -108,7 +136,7 @@ class ApiHandler(var ctx:Context) {
fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper
getRequest("/api/libraries") {
getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>()
if (it.has("value")) {
val array = it.getJSONArray("value")
@@ -121,24 +149,34 @@ class ApiHandler(var ctx:Context) {
}
}
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "getLibraryItem Failed")
cb(null)
} else {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
}
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) {
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
getRequest(requestUrl, null, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed")
cb(null)
} else {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
}
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1", null, null) {
val items = mutableListOf<LibraryItem>()
if (it.has("results")) {
val array = it.getJSONArray("results")
@@ -152,7 +190,7 @@ class ApiHandler(var ctx:Context) {
}
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized") {
getRequest("/api/libraries/$libraryId/personalized", null, null) {
val items = mutableListOf<LibraryCategory>()
if (it.has("value")) {
val array = it.getJSONArray("value")
@@ -172,36 +210,44 @@ class ApiHandler(var ctx:Context) {
}
}
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
val payload = JSObject()
payload.put("mediaPlayer", mediaPlayer)
// Only if direct play fails do we force transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
postRequest(endpoint, payload) {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
it.put("serverAddress", DeviceManager.serverAddress)
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
cb(playbackSession)
postRequest(endpoint, payload, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
cb(null)
} else {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
it.put("serverAddress", DeviceManager.serverAddress)
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
cb(playbackSession)
}
}
}
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) {
cb()
postRequest("/api/session/$sessionId/sync", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
} else {
cb(true)
}
}
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload) {
cb()
postRequest("/api/session/local", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
} else {
cb(true)
}
}
}
@@ -222,11 +268,13 @@ class ApiHandler(var ctx:Context) {
if (localMediaProgress.isNotEmpty()) {
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
postRequest("/api/me/sync-local-progress", payload) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
postRequest("/api/me/sync-local-progress", payload, null) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
if (it.toString() == "{}") {
Log.e(tag, "Progress sync received empty object")
} else if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Progress sync error")
} else {
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
@@ -257,4 +305,66 @@ class ApiHandler(var ctx:Context) {
cb()
}
}
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"
// TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
getRequest(endpoint, pingClient, serverConnectionConfig) {
if (it.has("error")) {
Log.e(tag, "getMediaProgress: Failed to get progress")
cb(null)
} else {
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
cb(progress)
}
}
}
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
val endpoint = "/api/session/$playbackSessionId"
getRequest(endpoint, null, null) {
val err = it.getString("error")
if (!err.isNullOrEmpty()) {
cb(null)
} else {
cb(jacksonMapper.readValue<PlaybackSession>(it.toString()))
}
}
}
fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) {
Log.d(tag, "pingServer: Pinging ${config.address}")
getRequest("/ping", pingClient, config) {
val success = it.getString("success")
if (success.isNullOrEmpty()) {
Log.d(tag, "pingServer: Ping ${config.address} Failed")
cb(false)
} else {
Log.d(tag, "pingServer: Ping ${config.address} Successful")
cb(true)
}
}
}
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
android:fillColor="@android:color/white"
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
</group>
</vector>
@@ -11,11 +11,5 @@
<path
android:fillColor="@android:color/white"
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
</group>
</vector>
Binary file not shown.

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:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
<path android:fillColor="@android:color/white" android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
<path android:fillColor="@android:color/white" android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
</vector>
@@ -2,6 +2,4 @@
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
<path android:fillColor="@android:color/white" android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
<path android:fillColor="@android:color/white" android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
</vector>
@@ -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="package_name">com.audiobookshelf.app</string>
<string name="custom_url_scheme">com.audiobookshelf.app</string>
<string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
</resources>
+10 -1
View File
@@ -17,6 +17,15 @@
<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>
</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 './defaultStyles.css';
body {
background-color: #262626;
@@ -62,6 +63,12 @@ body {
box-shadow: 2px 10px 8px #1111117e;
}
.altBookshelfDivider {
background: rgb(38 38 38);
/*background: linear-gradient(180deg, rgba(191, 193, 195, 1) 0%, rgb(156, 158, 159) 17%, rgb(114, 115, 117) 88%, rgb(120, 120, 122) 100%);*/
box-shadow: 2px 10px 8px #1111117e;
}
/*
Bookshelf Label
*/
@@ -77,6 +84,14 @@ Bookshelf Label
color: #fce3a6;
}
.altBookshelfLabel {
background-color: #2d3436;
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
border-color: rgb(255, 255, 255);
border-style: solid;
color: #ffffff;
}
.cover-bg {
width: calc(100% + 40px);
height: calc(100% + 40px);
+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>
</a>
<div v-if="user && currentLibrary">
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" />
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p>
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div>
</div>
<widgets-connection-indicator />
<div class="flex-grow" />
<widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span>
<span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div>
<nuxt-link v-if="user" class="h-7 mx-2" to="/search">
<nuxt-link v-if="user" class="h-7 mx-1.5" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span>
</nuxt-link>
<div class="h-7 mx-2">
<div class="h-7 mx-1.5">
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
</div>
</div>
+165 -46
View File
@@ -30,13 +30,17 @@
</div>
</div>
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
<span class="material-icons text-error text-3xl">error</span>
</div>
</div>
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden">
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
<p class="title-text font-book truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
</div>
@@ -63,21 +67,22 @@
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @click="clickTrack">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
</div>
<div id="timestamp-row" class="flex pt-0.5">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
@@ -108,6 +113,7 @@ export default {
},
data() {
return {
windowHeight: 0,
playbackSession: null,
showChapterModal: false,
showFullscreen: false,
@@ -121,18 +127,28 @@ export default {
isEnded: false,
volume: 0.5,
readyTrackWidth: 0,
playedTrackWidth: 0,
seekedTime: 0,
seekLoading: false,
onPlaybackSessionListener: null,
onPlaybackClosedListener: null,
onPlayingUpdateListener: null,
onMetadataListener: null,
onProgressSyncFailing: null,
onProgressSyncSuccess: null,
touchStartY: 0,
touchStartTime: 0,
touchEndY: 0,
useChapterTrack: false,
isLoading: false
isLoading: false,
touchTrackStart: false,
dragPercent: 0,
syncStatus: 0
}
},
watch: {
showFullscreen(val) {
this.updateScreenSize()
this.$store.commit('setPlayerFullscreen', !!val)
}
},
computed: {
@@ -150,14 +166,31 @@ export default {
})
return items
},
jumpForwardIcon() {
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
},
jumpBackwardsIcon() {
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
},
jumpForwardTime() {
return this.$store.getters['getJumpForwardTime']
},
jumpBackwardsTime() {
return this.$store.getters['getJumpBackwardsTime']
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
bookCoverWidth() {
if (this.showFullscreen) return this.fullscreenBookCoverWidth
return 60
},
fullscreenBookCoverWidth() {
var heightScale = (this.windowHeight - 200) / 651
if (this.bookCoverAspectRatio === 1) {
return this.showFullscreen ? 260 : 60
return 260 * heightScale
}
return this.showFullscreen ? 200 : 60
return 200 * heightScale
},
showCastBtn() {
return this.$store.state.isCastAvailable
@@ -268,6 +301,18 @@ export default {
}
},
methods: {
clickTitleAndAuthor() {
if (!this.showFullscreen) return
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
if (llid) {
this.$router.push(`/item/${llid}`)
this.showFullscreen = false
}
},
touchstartTrack(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
this.touchTrackStart = true
},
selectChapter(chapter) {
this.seek(chapter.start)
this.showChapterModal = false
@@ -324,13 +369,13 @@ export default {
restart() {
this.seek(0)
},
backward10() {
jumpBackwards() {
if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: 10 })
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
},
forward10() {
jumpForward() {
if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: 10 })
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
@@ -409,15 +454,11 @@ export default {
if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start
percentDone = currChapTime / this.currentChapterDuration
bufferedPercent = (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
}
var ptWidth = Math.round(percentDone * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
this.playedTrackWidth = ptWidth
if (this.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
@@ -440,7 +481,6 @@ export default {
var perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
@@ -490,7 +530,6 @@ export default {
var data = await AbsAudioPlayer.getCurrentTime()
this.currentTime = Number(data.value.toFixed(2))
this.bufferedTime = Number(data.bufferedTime.toFixed(2))
console.log('[AudioPlayer] Got Current Time', this.currentTime)
this.timeupdate()
}, 1000)
},
@@ -517,15 +556,60 @@ export default {
this.touchStartTime = Date.now()
},
touchend(e) {
if (!this.showFullscreen || !e.changedTouches) return
if (!e.changedTouches) return
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
if (this.touchTrackStart) {
var touch = e.changedTouches[0]
const touchOnTrackPos = touch.pageX - 12
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
var seekToTime = 0
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
seekToTime = this.currentChapter.start + currChapTime
} else {
seekToTime = dragPercent * this.totalDuration
}
this.seek(seekToTime)
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = '0px'
}
this.touchTrackStart = false
} else if (this.showFullscreen) {
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
}
this.handleGesture()
}
},
touchmove(e) {
if (!this.touchTrackStart) return
var touch = e.touches[0]
const touchOnTrackPos = touch.pageX - 12
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
this.dragPercent = dragPercent
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = this.dragPercent * this.trackWidth + 'px'
}
var ts = this.$refs.currentTimestamp
if (ts) {
var currTimeStr = ''
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
currTimeStr = this.$secondsToTimestamp(currChapTime)
} else {
const dragTime = dragPercent * this.totalDuration
currTimeStr = this.$secondsToTimestamp(dragTime)
}
ts.innerText = currTimeStr
}
this.handleGesture()
},
clickMenuAction(action) {
if (action === 'chapter_track') {
@@ -598,6 +682,7 @@ export default {
this.isEnded = false
this.isLoading = true
this.syncStatus = 0
this.$store.commit('setPlayerItem', this.playbackSession)
// Set track width
@@ -610,7 +695,6 @@ export default {
})
},
onPlaybackClosed() {
console.log('Received onPlaybackClosed evt')
this.endPlayback()
},
onPlaybackFailed(data) {
@@ -627,35 +711,76 @@ export default {
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
},
screenOrientationChange() {
setTimeout(this.updateScreenSize, 50)
},
updateScreenSize() {
this.windowHeight = window.innerHeight
var coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
},
minimizePlayerEvt() {
console.log('Minimize Player Evt')
this.showFullscreen = false
},
showProgressSyncIsFailing() {
this.syncStatus = this.$constants.SyncStatus.FAILED
},
showProgressSyncSuccess() {
this.syncStatus = this.$constants.SyncStatus.SUCCESS
}
},
mounted() {
this.updateScreenSize()
if (screen.orientation) {
screen.orientation.addEventListener('change', this.screenOrientationChange)
}
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
document.body.addEventListener('touchmove', this.touchmove)
this.$nextTick(this.init)
},
beforeDestroy() {
if (screen.orientation) {
screen.orientation.removeEventListener('change', this.screenOrientationChange)
}
if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback')
this.closePlayback()
}
this.forceCloseDropdownMenu()
this.$eventBus.$off('minimize-player', this.minimizePlayerEvt)
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove()
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
clearInterval(this.playInterval)
}
}
</script>
<style>
:root {
--cover-image-width: 0px;
--cover-image-height: 0px;
--cover-image-width-collapsed: 60px;
--cover-image-height-collapsed: 60px;
}
.bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066;
}
@@ -686,15 +811,12 @@ export default {
.cover-wrapper {
bottom: 44px;
left: 12px;
height: 96px;
width: 60px;
height: var(--cover-image-height-collapsed);
width: var(--cover-image-width-collapsed);
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height;
transform-origin: left bottom;
}
.cover-wrapper.square-cover {
height: 60px;
}
.total-track {
bottom: 215px;
@@ -726,16 +848,18 @@ export default {
}
.fullscreen .title-author-texts {
bottom: 36%;
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
width: 80%;
left: 10%;
text-align: center;
padding-bottom: calc(((260px - var(--cover-image-height)) / 260) * 40);
pointer-events: auto;
}
.fullscreen .title-author-texts .title-text {
font-size: 1.2rem;
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.2rem);
}
.fullscreen .title-author-texts .author-text {
font-size: 1rem;
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
}
#playerControls {
@@ -773,16 +897,11 @@ export default {
}
.fullscreen .cover-wrapper {
bottom: 46%;
left: calc(50% - 100px);
margin: 0 auto;
height: 320px;
width: 200px;
}
.fullscreen .cover-wrapper.square-cover {
height: 260px;
width: 260px;
left: calc(50% - 130px);
height: var(--cover-image-height);
width: var(--cover-image-width);
left: calc(50% - (calc(var(--cover-image-width)) / 2));
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
}
.fullscreen #playerControls {
+30 -10
View File
@@ -32,7 +32,8 @@ export default {
onMediaPlayerChangedListener: null,
sleepInterval: null,
currentEndOfChapterTime: 0,
serverLibraryItemId: null
serverLibraryItemId: null,
serverEpisodeId: null
}
},
watch: {
@@ -173,21 +174,27 @@ export default {
this.$toast.error(`Cannot cast locally downloaded media`)
} else {
// Change to server library item
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
this.playServerLibraryItemAndCast(this.serverLibraryItemId, this.serverEpisodeId)
}
},
playServerLibraryItemAndCast(libraryItemId) {
playServerLibraryItemAndCast(libraryItemId, episodeId) {
var playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: false, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
if (data.error) {
const errorMsg = data.error || 'Failed to play'
this.$toast.error(errorMsg)
} else {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
}
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to play')
})
},
async playLibraryItem(payload) {
@@ -197,6 +204,7 @@ export default {
// 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
var serverLibraryItemId = payload.serverLibraryItemId || null
var serverEpisodeId = payload.serverEpisodeId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({
@@ -209,6 +217,7 @@ export default {
}
this.serverLibraryItemId = null
this.serverEpisodeId = null
var playbackRate = 1
if (this.$refs.audioPlayer) {
@@ -218,15 +227,26 @@ export default {
console.log('Called playLibraryItem', libraryItemId)
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
if (data.error) {
const errorMsg = data.error || 'Failed to play'
this.$toast.error(errorMsg)
} else {
this.serverLibraryItemId = serverLibraryItemId
console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
} else {
this.serverLibraryItemId = serverLibraryItemId
}
if (episodeId && !episodeId.startsWith('local')) {
this.serverEpisodeId = episodeId
} else {
this.serverEpisodeId = serverEpisodeId
}
}
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to play')
})
},
pauseItem() {
+22 -10
View File
@@ -19,7 +19,7 @@
</div>
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
<p class="text-xs">{{ serverConnectionConfig.address }}</p>
<p class="text-xs text-gray-400" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
</div>
<div class="flex items-center">
<p class="text-xs">{{ $config.version }}</p>
@@ -71,6 +71,9 @@ export default {
serverConnectionConfig() {
return this.$store.state.user.serverConnectionConfig
},
serverSettings() {
return this.$store.state.serverSettings || {}
},
username() {
return this.user ? this.user.username : ''
},
@@ -96,6 +99,11 @@ export default {
text: 'Account',
to: '/account'
})
items.push({
icon: 'equalizer',
text: 'User Stats',
to: '/stats'
})
}
if (this.$platform !== 'ios') {
@@ -105,12 +113,12 @@ export default {
text: 'Local Media',
to: '/localMedia/folders'
})
// items.push({
// icon: 'settings',
// text: 'Settings',
// to: '/settings'
// })
}
items.push({
icon: 'settings',
text: 'Settings',
to: '/settings'
})
return items
},
currentRoutePath() {
@@ -122,11 +130,15 @@ export default {
this.show = false
},
async logout() {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (this.user) {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$router.push('/connect')
},
@@ -155,4 +167,4 @@ export default {
this.show = false
}
}
</script>
</script>
+18 -4
View File
@@ -1,9 +1,9 @@
<template>
<div id="bookshelf" class="w-full max-w-full h-full">
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div v-else class="flex border-t border-white border-opacity-10" />
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView || altViewEnabled ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView && !altViewEnabled" class="w-full absolute bottom-0 left-0 z-30 bookshelfDivider" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div v-else-if="showBookshelfListView" class="flex border-t border-white border-opacity-10" />
</div>
</template>
@@ -119,12 +119,23 @@ export default {
},
shelfHeight() {
if (this.showBookshelfListView) return this.entityHeight + 16
if (this.altViewEnabled) {
var extraTitleSpace = this.isBookEntity ? 80 : 40
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40
},
totalEntityCardWidth() {
if (this.showBookshelfListView) return this.entityWidth
// Includes margin
return this.entityWidth + 24
},
altViewEnabled() {
return this.$store.getters['getAltViewEnabled']
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize
}
},
methods: {
@@ -302,6 +313,9 @@ export default {
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
const entitiesPerPage = this.shelvesPerPage * this.entitiesPerShelf
this.booksPerFetch = Math.ceil(entitiesPerPage / 20) * 20 // Round up to the nearest 20
if (this.totalEntities) {
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
}
@@ -382,7 +396,7 @@ export default {
this.resetEntities()
}
},
libraryChanged(libid) {
libraryChanged() {
if (this.hasFilter) {
this.clearFilter()
} else {
+33 -9
View File
@@ -1,20 +1,24 @@
<template>
<div class="w-full relative">
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<div v-if="altViewEnabled" class="px-5 pb-3 pt-4">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
</div>
<div class="flex items-end px-3 max-w-full overflow-x-auto" :class="altViewEnabled ? '' : 'bookshelfRow'" :style="{ height: shelfHeight + 'px', paddingBottom: entityPaddingBottom + 'px' }">
<template v-for="(entity, index) in entities">
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" is-categorized class="mx-2 relative" />
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
</template>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<div v-if="!altViewEnabled" class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
<div class="w-full h-full flex items-center justify-center rounded-sm border shinyBlack">
<p class="transform text-xs">{{ label }}</p>
</div>
</div>
<div class="w-full h-5 z-40 bookshelfDivider"></div>
<div v-if="!altViewEnabled" class="w-full h-5 z-40 bookshelfDivider"></div>
</div>
</template>
@@ -32,23 +36,43 @@ export default {
return {}
},
computed: {
entityPaddingBottom() {
if (!this.altViewEnabled) return 0
if (this.type === 'authors') return 10
else if (this.type === 'series') return 40
return 60 * this.sizeMultiplier
},
shelfHeight() {
if (this.altViewEnabled) {
var extraTitleSpace = this.type === 'authors' ? 10 : this.type === 'series' ? 50 : 60
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40
},
bookWidth() {
var coverSize = 100
if (this.bookCoverAspectRatio === 1) return coverSize * 1.6
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.bookCoverAspectRatio === 1) return this.bookWidth
if (this.isCoverSquareAspectRatio) return this.bookWidth
return this.bookWidth * 1.6
},
entityHeight() {
return this.bookHeight
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.bookWidth / baseSize
},
isCoverSquareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
altViewEnabled() {
return this.$store.getters['getAltViewEnabled']
}
},
methods: {},
+11 -6
View File
@@ -6,13 +6,13 @@
</div>
<!-- Alternative bookshelf title/author/sort -->
<!-- <div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div> -->
</div>
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
@@ -84,7 +84,7 @@ export default {
},
bookCoverAspectRatio: Number,
showSequence: Boolean,
bookshelfView: Number,
isAltViewEnabled: Boolean,
bookMount: {
// Book can be passed as prop or set with setEntity()
type: Object,
@@ -239,7 +239,7 @@ export default {
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 === '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)
return null
},
@@ -250,7 +250,7 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
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)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@@ -346,6 +346,11 @@ export default {
return this.author.slice(0, 27) + '...'
}
return this.author
},
titleDisplayBottomOffset() {
if (!this.isAltViewEnabled) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
}
},
methods: {
+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" />
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(240, width) + 'px' }">
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
@@ -19,7 +19,8 @@ export default {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number
bookCoverAspectRatio: Number,
isAltViewEnabled: Boolean
},
data() {
return {
+3 -3
View File
@@ -16,7 +16,7 @@
</div>
<div class="flex-grow px-2">
<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 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>
@@ -109,8 +109,8 @@ export default {
},
episodes() {
if (this.isPodcast) {
if (this.media.numEpisodes==1) {
return "1 episode"
if (this.media.numEpisodes == 1) {
return '1 episode'
} else {
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" />
</div>
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(240, width) + 'px' }">
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
@@ -24,6 +27,7 @@ export default {
type: Object,
default: () => null
},
isAltViewEnabled: Boolean,
isCategorized: Boolean
},
data() {
+57 -20
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-full max-w-md mx-auto px-4 sm:px-6 lg:px-8 z-10">
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 w-full">
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
<template v-if="!showForm">
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
@@ -16,10 +16,18 @@
</template>
<div v-else class="w-full">
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
<span class="material-icons text-gray-300">arrow_back</span>
</div>
<h2 class="text-lg leading-7 mb-2">Server address</h2>
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full sm:w-72 h-10" />
<div class="flex justify-end">
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10 mt-4">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
<div class="flex justify-end items-center mt-6">
<!-- <div class="relative flex">
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
</div> -->
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div>
</form>
<template v-if="showAuth">
@@ -62,6 +70,8 @@
</svg>
</div>
</div>
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
</div>
</template>
@@ -69,23 +79,26 @@
import { Dialog } from '@capacitor/dialog'
export default {
props: {},
data() {
return {
deviceData: null,
loggedIn: false,
showAuth: false,
processing: false,
serverConfig: {
address: null,
username: null
username: null,
customHeaders: null
},
password: null,
error: null,
showForm: false
showForm: false,
showAddCustomHeaders: false
}
},
computed: {
deviceData() {
return this.$store.state.deviceData || {}
},
networkConnected() {
return this.$store.state.networkConnected
},
@@ -98,9 +111,16 @@ export default {
lastServerConnectionConfig() {
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
},
numCustomHeaders() {
if (!this.serverConfig.customHeaders) return 0
return Object.keys(this.serverConfig.customHeaders).length
}
},
methods: {
addCustomHeaders() {
this.showAddCustomHeaders = true
},
showServerList() {
this.showForm = false
this.showAuth = false
@@ -132,7 +152,7 @@ export default {
var payload = await this.authenticateToken()
if (payload) {
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
this.setUserAndConnection(payload)
} else {
this.showAuth = true
}
@@ -147,7 +167,10 @@ export default {
if (value) {
this.processing = true
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
this.deviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
const updatedDeviceData = { ...this.deviceData }
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
this.$store.commit('setDeviceData', updatedDeviceData)
this.serverConfig = {
address: null,
userId: null,
@@ -165,7 +188,6 @@ export default {
}
this.showForm = true
this.showAuth = true
console.log('Edit server config', serverConfig)
},
newServerConfigClick() {
this.serverConfig = {
@@ -192,9 +214,13 @@ export default {
return null
}
},
pingServerAddress(address) {
pingServerAddress(address, customHeaders) {
const options = { timeout: 3000 }
if (customHeaders) {
options.headers = customHeaders
}
return this.$axios
.$get(`${address}/ping`, { timeout: 3000 })
.$get(`${address}/ping`, options)
.then((data) => data.success)
.catch((error) => {
console.error('Server check failed', error)
@@ -203,8 +229,12 @@ export default {
})
},
requestServerLogin() {
const options = {}
if (this.serverConfig.customHeaders) {
options.headers = this.serverConfig.customHeaders
}
return this.$axios
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password })
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
.then((data) => {
if (!data.user) {
console.error(data.error)
@@ -236,7 +266,7 @@ export default {
this.processing = true
this.error = null
var success = await this.pingServerAddress(this.serverConfig.address)
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
this.processing = false
if (success) this.showAuth = true
},
@@ -246,20 +276,29 @@ export default {
this.error = 'Invalid username'
return
}
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === this.serverConfig.username && this.serverConfig.id !== scc.id)
if (duplicateConfig) {
this.error = 'Config already exists for this address and username'
return
}
this.error = null
this.processing = true
var payload = await this.requestServerLogin()
this.processing = false
if (payload) {
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
this.setUserAndConnection(payload)
}
},
async setUserAndConnection(user, userDefaultLibraryId) {
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) {
if (!user) return
console.log('Successfully logged in', JSON.stringify(user))
this.$store.commit('setServerSettings', serverSettings)
// Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
@@ -298,8 +337,6 @@ export default {
return authRes
},
async init() {
this.deviceData = await this.$db.getDeviceData()
if (this.lastServerConnectionConfig) {
this.connectToServer(this.lastServerConnectionConfig)
} else {
+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="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<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'">
<div class="relative flex items-center px-2">
<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>
+3 -2
View File
@@ -10,7 +10,7 @@
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div>
@@ -30,7 +30,8 @@ export default {
items: {
type: Array,
default: () => []
}
},
selected: String // optional
},
data() {
return {}
+9 -1
View File
@@ -86,6 +86,11 @@ export default {
value: 'narrators',
sublist: true
},
{
text: 'Language',
value: 'languages',
sublist: true
},
{
text: 'Progress',
value: 'progress',
@@ -165,8 +170,11 @@ export default {
narrators() {
return this.filterData.narrators || []
},
languages() {
return this.filterData.languages || []
},
progress() {
return ['Finished', 'In Progress', 'Not Started']
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
+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>
+52
View File
@@ -0,0 +1,52 @@
<template>
<modals-modal v-model="show" :width="400" height="100%">
<template #outer>
<div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Details</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 class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
<p class="mb-1">{{ mediaMetadata.title }}</p>
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_libraryItem() {
return this.libraryItem || {}
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
}
},
methods: {},
mounted() {}
}
</script>
+1 -1
View File
@@ -51,7 +51,7 @@ export default {
async clickedOption(lib) {
this.show = false
await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed', lib.id)
this.$eventBus.$emit('library-changed')
this.$localStore.setLastLibraryId(lib.id)
}
},
+17 -1
View File
@@ -6,7 +6,7 @@
<span class="material-icons text-4xl">close</span>
</div>
<slot name="outer" />
<div ref="content" style="max-width: 90%; min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
<div ref="content" style="min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
<slot />
</div>
</div>
@@ -28,6 +28,10 @@ export default {
height: {
type: [String, Number],
default: 'unset'
},
maxWidth: {
type: String,
default: '90%'
}
},
data() {
@@ -73,6 +77,8 @@ export default {
}
},
setShow() {
this.$store.commit('globals/setIsModalOpen', true)
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
@@ -80,18 +86,28 @@ export default {
document.documentElement.classList.add('modal-open')
},
setHide() {
this.$store.commit('globals/setIsModalOpen', false)
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
},
closeModalEvt() {
console.log('Close modal event')
this.show = false
}
},
mounted() {
this.$eventBus.$on('close-modal', this.closeModalEvt)
this.el = this.$refs.wrapper
this.content = this.$refs.content
this.content.style.transform = 'scale(0)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.style.opacity = 1
this.el.remove()
},
beforeDestroy() {
this.$eventBus.$off('close-modal', this.closeModalEvt)
}
}
</script>
+9 -7
View File
@@ -1,12 +1,12 @@
<template>
<modals-modal v-model="show" :width="200" height="100%">
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
<template #outer>
<div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Playback Speed</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="closeMenu">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="rate in rates">
@@ -91,15 +91,17 @@ export default {
var newPlaybackRate = this.selected - 0.1
this.selected = Number(newPlaybackRate.toFixed(1))
},
closeMenu() {
if (this.currentPlaybackRate !== this.selected) {
this.$emit('change', this.selected)
modalInput(val) {
if (!val) {
if (this.currentPlaybackRate !== this.selected) {
this.$emit('change', this.selected)
}
}
this.show = false
},
clickedOption(rate) {
this.selected = Number(rate)
this.$nextTick(this.closeMenu)
this.show = false
this.$emit('change', Number(rate))
}
},
mounted() {}
+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>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary">
<p>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>
<p class="pr-4">Collection List</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 class="flex-grow" />
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div>
<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" />
@@ -39,12 +41,12 @@ export default {
totalDuration() {
var _total = 0
this.books.forEach((book) => {
_total += book.duration
_total += book.media.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPretty(this.totalDuration)
return this.$elapsedPrettyExtended(this.totalDuration)
}
},
methods: {
+15 -7
View File
@@ -1,13 +1,14 @@
<template>
<div class="w-full px-2 py-2 overflow-hidden relative">
<div v-if="book" class="flex h-20">
<div v-if="book" class="flex w-full">
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="w-80 h-full px-2 flex items-center">
<div>
<nuxt-link :to="`/item/${book.id}`" class="truncate text-sm">{{ bookTitle }}</nuxt-link>
<div class="flex-grow book-table-content h-full px-2 flex items-center">
<div class="max-w-full">
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
</div>
</div>
</div>
@@ -46,13 +47,13 @@ export default {
return this.mediaMetadata.authorName || ''
},
bookDuration() {
return this.$secondsToTimestamp(this.media.duration)
return this.$elapsedPretty(this.media.duration)
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
bookWidth() {
if (this.bookCoverAspectRatio === 1) return 80
if (this.bookCoverAspectRatio === 1) return 50
return 50
},
isMissing() {
@@ -78,4 +79,11 @@ export default {
},
mounted() {}
}
</script>
</script>
<style>
.book-table-content {
width: calc(100% - 50px);
max-width: calc(100% - 50px);
}
</style>
+5 -4
View File
@@ -16,9 +16,8 @@
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">
{{ description }}
</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5 default-style" v-html="description" />
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
@@ -200,7 +199,9 @@ export default {
this.$eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: this.localEpisode.id
episodeId: this.localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.episode.id
})
} else {
this.$eventBus.$emit('play-item', {
+126 -4
View File
@@ -1,10 +1,28 @@
<template>
<div class="w-full">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodes.length }})</p>
<div class="flex items-center">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
<div class="flex-grow" />
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
<span class="material-icons text-xl text-gray-200">filter_alt</span>
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
</button>
<template v-for="episode in episodes">
<div class="flex items-center border border-white border-opacity-25 rounded px-2" @click="clickSort">
<p class="text-sm text-gray-200">{{ sortText }}</p>
<span class="material-icons ml-1 text-gray-200">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</div>
<template v-for="episode in episodesSorted">
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" />
</template>
<!-- What in tarnation is going on here?
Without anything below the template it will not re-render -->
<p>&nbsp;</p>
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
</div>
</template>
@@ -24,9 +42,86 @@ export default {
isLocal: Boolean // If is local then episodes and libraryItemId are local, otherwise local is passed in localLibraryItemId and localEpisodes
},
data() {
return {}
return {
episodesCopy: [],
showFiltersModal: false,
sortKey: 'publishedAt',
sortDesc: true,
filterKey: 'incomplete',
episodeSortItems: [
{
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Season',
value: 'season'
},
{
text: 'Episode',
value: 'episode'
}
],
filterItems: [
{
text: 'Show All',
value: 'all'
},
{
text: 'Incomplete',
value: 'incomplete'
},
{
text: 'In Progress',
value: 'inProgress'
},
{
text: 'Complete',
value: 'complete'
}
]
}
},
watch: {
episodes: {
immediate: true,
handler() {
this.init()
}
}
},
computed: {
episodesAreFiltered() {
return this.episodesFiltered.length !== this.episodesCopy.length
},
episodesFiltered() {
return this.episodesCopy.filter((ep) => {
var mediaProgress = this.getEpisodeProgress(ep)
if (this.filterKey === 'incomplete') {
return !mediaProgress || !mediaProgress.isFinished
} else if (this.filterKey === 'complete') {
return mediaProgress && mediaProgress.isFinished
} else if (this.filterKey === 'inProgress') {
return mediaProgress && !mediaProgress.isFinished
} else if (this.filterKey === 'all') {
console.log('Filter key is all')
return true
}
return true
})
},
episodesSorted() {
return this.episodesFiltered.sort((a, b) => {
if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
})
},
// Map of local episodes where server episode id is key
localEpisodeMap() {
var epmap = {}
@@ -36,9 +131,36 @@ export default {
}
})
return epmap
},
sortText() {
if (!this.sortKey) return ''
var _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
if (!_sel) return ''
return _sel.text
}
},
methods: {
setFilter(filter) {
this.filterKey = filter
console.log('Set filter', this.filterKey)
this.showFiltersModal = false
},
showFilters() {
this.showFiltersModal = true
},
clickSort() {
this.sortDesc = !this.sortDesc
},
getEpisodeProgress(episode) {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
},
init() {
this.episodesCopy = this.episodes.map((ep) => {
return { ...ep }
})
}
},
methods: {},
mounted() {}
}
</script>
@@ -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 */; };
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
@@ -59,6 +60,7 @@
4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; };
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -134,6 +136,7 @@
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
);
path = models;
sourceTree = "<group>";
@@ -325,6 +328,7 @@
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
@@ -475,12 +479,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.46;
MARKETING_VERSION = 0.9.56;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -499,12 +503,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.46;
MARKETING_VERSION = 0.9.56;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+15
View File
@@ -1,5 +1,6 @@
import UIKit
import Capacitor
import RealmSwift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -8,6 +9,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
let configuration = Realm.Configuration(
schemaVersion: 1,
migrationBlock: { migration, oldSchemaVersion in
if (oldSchemaVersion < 1) {
NSLog("Realm schema version was \(oldSchemaVersion)")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["enableAltView"] = false
}
}
}
)
Realm.Configuration.defaultConfiguration = configuration
return true
}
+13 -8
View File
@@ -23,8 +23,9 @@ public class AbsAudioPlayer: CAPPlugin {
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
}
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
let libraryItemId = call.getString("libraryItemId")
let episodeId = call.getString("episodeId")
@@ -43,10 +44,10 @@ public class AbsAudioPlayer: CAPPlugin {
initialPlayWhenReady = playWhenReady
initialPlaybackRate = playbackRate
PlayerHandler.stopPlayback()
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
do {
self.sendPlaybackSession(session: try session.asDictionary())
call.resolve(try session.asDictionary())
@@ -56,6 +57,8 @@ public class AbsAudioPlayer: CAPPlugin {
call.resolve([:])
}
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
self.sendMetadata()
}
}
@@ -131,24 +134,26 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func setSleepTimer(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") 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))")
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 ]) }
NSLog("oof \(timeToPause)")
PlayerHandler.sleepTimerChapterStopTime = timeSeconds
PlayerHandler.remainingSleepTime = timeToPause
return call.resolve([ "success": true ])
}
PlayerHandler.remainingSleepTime = time / 1000
PlayerHandler.sleepTimerChapterStopTime = nil
PlayerHandler.remainingSleepTime = timeSeconds
call.resolve([ "success": true ])
}
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
PlayerHandler.remainingSleepTime = nil
PlayerHandler.sleepTimerChapterStopTime = nil
call.resolve()
}
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
@@ -173,7 +178,7 @@ public class AbsAudioPlayer: CAPPlugin {
let playbackSession = PlayerHandler.getPlaybackSession()
let libraryItemId = playbackSession?.libraryItemId ?? ""
let episodeId = playbackSession?.episodeId ?? nil
NSLog("TEST: Forcing Transcode")
NSLog("Forcing Transcode")
// If direct playing then fallback to transcode
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
+3 -1
View File
@@ -17,7 +17,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
CAP_PLUGIN_METHOD(getLocalLibraryItems, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItem, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemByLLId, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
)
+22 -2
View File
@@ -65,11 +65,12 @@ public class AbsDatabase: CAPPlugin {
@objc func getDeviceData(_ call: CAPPluginCall) {
let configs = Database.shared.getServerConnectionConfigs()
let index = Database.shared.getLastActiveConfigIndex()
let settings = Database.shared.getDeviceSettings()
call.resolve([
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
// "currentLocalPlaybackSession": nil,
"deviceSettings": deviceSettingsToJSON(settings: settings)
])
}
@@ -79,10 +80,29 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
call.resolve()
}
@objc func getLocalLibraryItemByLLId(_ call: CAPPluginCall) {
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
call.resolve()
}
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
call.resolve([ "value": [] ])
}
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
call.resolve([ "value": [] ])
}
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
let enableAltView = call.getBool("enableAltView") ?? false
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
let settings = DeviceSettings()
settings.disableAutoRewind = disableAutoRewind
settings.enableAltView = enableAltView
settings.jumpBackwardsTime = jumpBackwardsTime
settings.jumpForwardTime = jumpForwardTime
Database.shared.setDeviceSettings(deviceSettings: settings)
// call.resolve([ "value": [] ])
getDeviceData(call)
}
}
@@ -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
]
}
}
+12 -11
View File
@@ -45,6 +45,7 @@ class AudioPlayer: NSObject {
self.playWhenReady = playWhenReady
self.initialPlaybackRate = playbackRate
self.audioPlayer = AVQueuePlayer()
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
self.playbackSession = playbackSession
self.status = -1
self.rate = 0.0
@@ -65,10 +66,10 @@ class AudioPlayer: NSObject {
}
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
NSLog("TEST: Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
NSLog("Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
NSLog("TEST: Setting player items \(playerItems.count)")
NSLog("Setting player items \(playerItems.count)")
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
@@ -122,7 +123,7 @@ class AudioPlayer: NSObject {
self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) {
NSLog("TEST: New Current track index \(self.currentTrackIndex)")
NSLog("New Current track index \(self.currentTrackIndex)")
}
}
}
@@ -132,7 +133,7 @@ class AudioPlayer: NSObject {
self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
if (playerItem.status == .readyToPlay) {
NSLog("TEST: queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
self.updateNowPlaying()
let firstReady = self.status < 0
@@ -145,7 +146,7 @@ class AudioPlayer: NSObject {
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
}
} else if (playerItem.status == .failed) {
NSLog("TEST: queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
@@ -202,16 +203,16 @@ class AudioPlayer: NSObject {
pause()
NSLog("TEST: Seek to \(to) from \(from)")
NSLog("Seek to \(to) from \(from)")
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration
NSLog("TEST: Seek current track END = \(trackEnd)")
NSLog("Seek current track END = \(trackEnd)")
let indexOfSeek = getItemIndexForTime(time: to)
NSLog("TEST: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
// Reconstruct queue if seeking to a different track
if (self.currentTrackIndex != indexOfSeek) {
@@ -230,7 +231,7 @@ class AudioPlayer: NSObject {
setupQueueItemStatusObserver()
} else {
NSLog("TEST: Seeking in current item \(to)")
NSLog("Seeking in current item \(to)")
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = to - currentTrackStartOffset
@@ -249,7 +250,7 @@ class AudioPlayer: NSObject {
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
if self.audioPlayer.rate != rate {
NSLog("TEST: setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
self.audioPlayer.rate = rate
}
if rate > 0.0 && !(observed && rate == 1) {
@@ -372,7 +373,7 @@ class AudioPlayer: NSObject {
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &playerContext {
if keyPath == #keyPath(AVPlayer.rate) {
NSLog("TEST: playerContext observer player rate")
NSLog("playerContext observer player rate")
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
} else if keyPath == #keyPath(AVPlayer.currentItem) {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
+31 -10
View File
@@ -11,7 +11,9 @@ class PlayerHandler {
private static var player: AudioPlayer?
private static var session: PlaybackSession?
private static var timer: Timer?
private static var lastSyncTime:Double = 0.0
public static var sleepTimerChapterStopTime: Int? = nil
private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? {
get {
@@ -126,17 +128,27 @@ class PlayerHandler {
private static func tick() {
if !paused {
listeningTimePassedSinceLastSync += 1
}
if listeningTimePassedSinceLastSync > 3 {
syncProgress()
}
if remainingSleepTime != nil {
if remainingSleepTime! == 0 {
paused = true
if remainingSleepTime != nil {
if sleepTimerChapterStopTime != nil {
let timeUntilChapterEnd = Double(sleepTimerChapterStopTime ?? 0) - (getCurrentTime() ?? 0)
if timeUntilChapterEnd <= 0 {
paused = true
remainingSleepTime = nil
} else {
remainingSleepTime = Int(timeUntilChapterEnd.rounded())
}
} else {
if remainingSleepTime! <= 0 {
paused = true
}
remainingSleepTime! -= 1
}
}
remainingSleepTime! -= 1
}
if listeningTimePassedSinceLastSync >= 5 {
syncProgress()
}
}
public static func syncProgress() {
@@ -149,6 +161,15 @@ class PlayerHandler {
return
}
// Prevent multiple sync requests
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
if (lastSyncTime > 0 && timeSinceLastSync < 1) {
NSLog("syncProgress last sync time was < 1 second so not syncing")
return
}
lastSyncTime = Date().timeIntervalSince1970 // seconds
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = playerCurrentTime
+15 -2
View File
@@ -17,7 +17,7 @@ class ApiClient {
}).resume()
}
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: String], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: Any], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
return
@@ -27,7 +27,7 @@ class ApiClient {
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers).responseDecodable(of: decodable) { response in
switch response.result {
case .success(let obj):
callback?(obj)
@@ -88,10 +88,23 @@ class ApiClient {
endpoint += "/\(episodeId!)"
}
var systemInfo = utsname()
uname(&systemInfo)
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
ptr in String.init(validatingUTF8: ptr)
}
}
ApiClient.postResource(endpoint: endpoint, parameters: [
"forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer",
"deviceInfo": [
"manufacturer": "Apple",
"model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
]
], decodable: PlaybackSession.self) { obj in
var session = obj
+20
View File
@@ -128,4 +128,24 @@ class Database {
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
}
}
public func setDeviceSettings(deviceSettings: DeviceSettings) {
Database.realmQueue.sync {
let existing = instance.objects(DeviceSettings.self)
do {
try instance.write {
instance.delete(existing)
instance.add(deviceSettings)
}
} catch(let exception) {
NSLog("failed to save device settings")
debugPrint(exception)
}
}
}
public func getDeviceSettings() -> DeviceSettings {
return Database.realmQueue.sync {
return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
}
}
}
+71 -18
View File
@@ -66,9 +66,6 @@ export default {
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
isSocketConnected() {
return this.$store.state.socketConnected
}
},
methods: {
@@ -112,7 +109,7 @@ export default {
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
var authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` } }).catch((error) => {
var authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` }, timeout: 3000 }).catch((error) => {
console.error('[Server] Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
@@ -123,7 +120,8 @@ export default {
return
}
const { user, userDefaultLibraryId } = authRes
const { user, userDefaultLibraryId, serverSettings } = authRes
this.$store.commit('setServerSettings', serverSettings)
// Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId()
@@ -154,15 +152,9 @@ export default {
// Only cancels stream if streamining not playing downloaded
this.$eventBus.$emit('close-stream')
},
socketConnectionUpdate(isConnected) {
console.log('Socket connection update', isConnected)
},
socketConnectionFailed(err) {
this.$toast.error('Socket connection error: ' + err.message)
},
socketInit(data) {
console.log('Socket init', data)
},
async initLibraries() {
if (this.inittingLibraries) {
return
@@ -170,13 +162,14 @@ export default {
this.inittingLibraries = true
await this.$store.dispatch('libraries/load')
console.log(`[default] initLibraries loaded ${this.currentLibraryId}`)
await this.$store.dispatch('libraries/fetch', this.currentLibraryId)
this.$eventBus.$emit('library-changed')
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
this.inittingLibraries = false
},
async syncLocalMediaProgress() {
if (!this.user) {
console.log('[default] No need to sync local media progress - not connected to server')
this.$store.commit('setLastLocalMediaSyncResults', null)
return
}
@@ -184,10 +177,15 @@ export default {
var response = await this.$db.syncLocalMediaProgressWithServer()
if (!response) {
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
this.$store.commit('setLastLocalMediaSyncResults', null)
return
}
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response
if (numLocalMediaProgressForServer > 0) {
response.syncedAt = Date.now()
response.serverConfigName = this.$store.getters['user/getServerConfigName']
this.$store.commit('setLastLocalMediaSyncResults', response)
if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) {
console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`)
} else {
@@ -195,23 +193,79 @@ export default {
}
} else {
console.log('[default] syncLocalMediaProgress No local media progress to sync')
this.$store.commit('setLastLocalMediaSyncResults', null)
}
},
userUpdated(user) {
async userUpdated(user) {
if (this.user && this.user.id == user.id) {
this.$store.commit('user/setUser', user)
}
},
async userMediaProgressUpdated(prog) {
console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`)
// Update local media progress if exists
var localProg = this.$store.getters['globals/getLocalMediaProgressByServerItemId'](prog.libraryItemId, prog.episodeId)
var newLocalMediaProgress = null
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
// Server progress is more up-to-date
console.log(`[default] syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''}`)
const payload = {
localMediaProgressId: localProg.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
} else if (!localProg) {
// Check if local library item exists
// local media progress may not exist yet if it hasn't been played
var localLibraryItem = await this.$db.getLocalLibraryItemByLId(prog.libraryItemId)
if (localLibraryItem) {
if (prog.episodeId) {
// If episode check if local episode exists
var lliEpisodes = localLibraryItem.media.episodes || []
var localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
if (localEpisode) {
// Add new local media progress
const payload = {
localLibraryItemId: localLibraryItem.id,
localEpisodeId: localEpisode.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
}
} else {
// Add new local media progress
const payload = {
localLibraryItemId: localLibraryItem.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
}
} else {
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
}
}
if (newLocalMediaProgress && newLocalMediaProgress.id) {
console.log(`[default] local media progress updated for ${newLocalMediaProgress.id}`)
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
}
}
},
async mounted() {
this.$socket.on('connection-update', this.socketConnectionUpdate)
this.$socket.on('initialized', this.socketInit)
this.$socket.on('user_updated', this.userUpdated)
this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated)
if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false)
const deviceData = await this.$db.getDeviceData()
this.$store.commit('setDeviceData', deviceData)
await this.$store.dispatch('setupNetworkListener')
await this.$store.dispatch('globals/loadLocalMediaProgress')
if (this.$store.state.user.serverConnectionConfig) {
console.log(`[default] server connection config set - call init libraries`)
await this.initLibraries()
@@ -222,15 +276,14 @@ export default {
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
await this.syncLocalMediaProgress()
this.$store.dispatch('globals/loadLocalMediaProgress')
this.loadSavedSettings()
this.hasMounted = true
}
},
beforeDestroy() {
this.$socket.off('connection-update', this.socketConnectionUpdate)
this.$socket.off('initialized', this.socketInit)
this.$socket.off('user_updated', this.userUpdated)
this.$socket.off('user_media_progress_updated', this.userMediaProgressUpdated)
}
}
</script>
+3 -2
View File
@@ -45,7 +45,8 @@ export default {
index,
width: this.entityWidth,
height: this.entityHeight,
bookCoverAspectRatio: this.bookCoverAspectRatio
bookCoverAspectRatio: this.bookCoverAspectRatio,
isAltViewEnabled: this.altViewEnabled
}
if (this.entityName === 'series-books') props.showSequence = true
if (this.entityName === 'books') {
@@ -54,7 +55,7 @@ export default {
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
}
var _this = this
// var _this = this
var instance = new ComponentClass({
propsData: props,
created() {
+3 -2
View File
@@ -1,11 +1,12 @@
{
"name": "audiobookshelf-app",
"version": "0.9.46-beta",
"version": "0.9.56-beta",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"version": "0.9.44-beta",
"name": "audiobookshelf-app",
"version": "0.9.56-beta",
"dependencies": {
"@capacitor/android": "^3.4.3",
"@capacitor/app": "^1.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "0.9.46-beta",
"version": "0.9.56-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337",

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