Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7031c895b5 | ||
|
|
7c739bf3bf | ||
|
|
facf56930e | ||
|
|
2c3dff3544 | ||
|
|
b1bf68b8cd | ||
|
|
78aade7694 | ||
|
|
83a4474375 | ||
|
|
1b47c38efa | ||
|
|
922d9f22ce | ||
|
|
d7feb52dd8 | ||
|
|
fafaad8b5f | ||
|
|
928f33005a | ||
|
|
0448d20583 | ||
|
|
f9d408b33f | ||
|
|
9386ae95a3 | ||
|
|
c530e0f7e3 | ||
|
|
e39a5e1ba3 | ||
|
|
cb9e317e74 | ||
|
|
8061ac0ced | ||
|
|
daff8355d7 | ||
|
|
231289e9ac | ||
|
|
87df022af0 | ||
|
|
07fdac9746 | ||
|
|
0d482aaa42 | ||
|
|
d48d1fdde3 | ||
|
|
65b69cc020 | ||
|
|
a3794a6a6a | ||
|
|
695ab84940 | ||
|
|
5399ec67d7 | ||
|
|
d9a6ec65df | ||
|
|
e842de78dd | ||
|
|
260363bf4f | ||
|
|
6d7dffd4de | ||
|
|
ef1d8a8eb4 | ||
|
|
cdd7a1a636 | ||
|
|
6584cd1522 | ||
|
|
d01b602306 | ||
|
|
c90f2c136a | ||
|
|
15ccc7192e | ||
|
|
e4345a4438 | ||
|
|
3f4d564bad | ||
|
|
3311f66da8 | ||
|
|
e711ada0b3 | ||
|
|
840979d635 | ||
|
|
53c9109534 | ||
|
|
87ca6018d3 | ||
|
|
c590087578 | ||
|
|
3963ffa0e2 | ||
|
|
eaa0c9914a | ||
|
|
d9b0b8c33d | ||
|
|
fb4e7e6b55 | ||
|
|
78a063a669 | ||
|
|
cb5d32bbb9 | ||
|
|
f594b734c0 | ||
|
|
bf62b82037 | ||
|
|
39fbb0902e | ||
|
|
7818500f0f | ||
|
|
1ea984b915 | ||
|
|
f28405081a | ||
|
|
317f00e1b8 | ||
|
|
f8ce0b8355 | ||
|
|
9fd6ff9fd2 | ||
|
|
3931020c83 | ||
|
|
8bdb6b0b66 | ||
|
|
33bdee66a3 | ||
|
|
a81f50878e | ||
|
|
322f7d75dd | ||
|
|
2f34da4e99 | ||
|
|
5a9e30b08c | ||
|
|
04e233816d | ||
|
|
3ad1042b13 | ||
|
|
4fb9201823 | ||
|
|
02089e1489 | ||
|
|
da442f4a17 | ||
|
|
a0f8e6fb93 | ||
|
|
20cf5a6132 | ||
|
|
f5ca30620f | ||
|
|
72ec9d81b1 | ||
|
|
29568412e6 | ||
|
|
f94140ec6b | ||
|
|
51062e7403 | ||
|
|
0942bd2548 | ||
|
|
5939fee66f | ||
|
|
68560fd5f5 | ||
|
|
a38b826898 | ||
|
|
0158ccc4c8 | ||
|
|
a5ba6c64c9 | ||
|
|
d67454db9a | ||
|
|
fb35d8b1a0 | ||
|
|
960f75bfba | ||
|
|
83027a5787 | ||
|
|
bc6ef96465 | ||
|
|
ff77dbf3f8 | ||
|
|
3f78fb7a14 | ||
|
|
152904b7c7 | ||
|
|
9214fecc86 | ||
|
|
049397f7ab | ||
|
|
0f42a411a9 | ||
|
|
4575c61118 | ||
|
|
7575a4f883 | ||
|
|
8d4c322bce | ||
|
|
28989f536a | ||
|
|
3318f3501c | ||
|
|
c9ad0c7897 | ||
|
|
018a988124 | ||
|
|
750c370692 | ||
|
|
08ee08c698 | ||
|
|
d899fd4d89 | ||
|
|
aab44d8cee | ||
|
|
2c2fac6e34 | ||
|
|
47ccc54749 | ||
|
|
05d7d0d0d8 | ||
|
|
210fb7b88f | ||
|
|
4eeb7f24d8 | ||
|
|
9496ee1d97 | ||
|
|
f4b05a3236 | ||
|
|
b73763a9ec | ||
|
|
e3aad8c44f | ||
|
|
1e7c298aa2 | ||
|
|
b9652c7cd8 | ||
|
|
04f00d2994 | ||
|
|
3da6728f4c | ||
|
|
5704ca2ae8 | ||
|
|
6cd04e7098 | ||
|
|
290c3560d2 | ||
|
|
40bda3d48b | ||
|
|
63a838dc9d | ||
|
|
127bf7d4e0 | ||
|
|
9af4947232 | ||
|
|
3233a2a078 | ||
|
|
28ede56c7c | ||
|
|
5f6c1f57b0 | ||
|
|
ec704bf501 | ||
|
|
5e0c740f02 | ||
|
|
a185c3abe9 | ||
|
|
e8c6fc635f | ||
|
|
f362db3855 | ||
|
|
9ae584665e | ||
|
|
1aa852c3dc | ||
|
|
ef46ad7a93 | ||
|
|
cbd74d08e1 | ||
|
|
851d0357a0 | ||
|
|
fb5c23d190 | ||
|
|
1743d0e13f | ||
|
|
25af0c4170 | ||
|
|
4bcb77250d | ||
|
|
6114e03cb7 | ||
|
|
9866a787bd | ||
|
|
20e64523b2 | ||
|
|
50a53ec0d1 | ||
|
|
40b2ff5a97 | ||
|
|
48cc000b95 | ||
|
|
75aaf4d784 | ||
|
|
23220ecae6 | ||
|
|
94b4dda94f | ||
|
|
86c5d1a3e9 | ||
|
|
d7be01935f | ||
|
|
b2b647e2f7 | ||
|
|
405cd21c32 | ||
|
|
17a894f5ef | ||
|
|
23c90f4118 | ||
|
|
f215efdcd0 | ||
|
|
2f243787ce | ||
|
|
39909a398e | ||
|
|
5e98a4ff2f | ||
|
|
8710775872 | ||
|
|
99d60eaf9a | ||
|
|
55b7f05685 | ||
|
|
944aa5de66 | ||
|
|
a12eb7efdf | ||
|
|
62a5a2d069 | ||
|
|
ebd65b4fa7 | ||
|
|
152cce2e9e | ||
|
|
ea847a62ff | ||
|
|
2a5ec618f0 | ||
|
|
3fc89377b4 | ||
|
|
93c16e6a62 |
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<meta http-equiv=content-type content="text/html; charset=utf-8" />
|
||||
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||
<title>Audiobookshelf :: Testers</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: rgb(55, 56, 56);
|
||||
font-family: Sans;
|
||||
margin: 40px auto;
|
||||
max-width: 500px;
|
||||
color: #ddd;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
}
|
||||
img {
|
||||
width: 100px;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 100;
|
||||
}
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
#apk {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: center;
|
||||
column-gap: 15px;
|
||||
text-align: left;
|
||||
font-family: monospace;
|
||||
background-color: #222;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a href="https://audiobookshelf.org">
|
||||
<img src=logo.png />
|
||||
</a>
|
||||
<h1>Audiobookshelf :: Testers</h1>
|
||||
|
||||
<p>
|
||||
Here you can download the latest version of Audiobookshelf for Android for <b>testing</b>.
|
||||
The app is automatically built for every new patch added to the project.
|
||||
</p>
|
||||
|
||||
<div id=apk>
|
||||
<div>Built on</div>
|
||||
<div>__DATE__</div>
|
||||
|
||||
<div>Git commit</div>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app/commit/__COMMIT__">__COMMIT__</a>
|
||||
|
||||
<div>Download</div>
|
||||
<a href="__APK__">__APK__</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Using this requires some technical knowledge to install the APK manually.
|
||||
Regular users should use the releases published on the <a href="https://play.google.com/store/apps/details?id=com.audiobookshelf.app">Play Store</a>.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>Report bugs, request features, provide feedback, and contribute on <a href="https://github.com/advplyr/audiobookshelf-app/issues">GitHub</a></p>
|
||||
<p>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app/issues">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width=32 height=32 viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Build APK
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Publish Test App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: ./ghpages
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
@@ -35,8 +35,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 92
|
||||
versionName "0.9.61-beta"
|
||||
versionCode 95
|
||||
versionName "0.9.64-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -74,8 +74,6 @@ configurations.all {
|
||||
force("androidx.media:media:$androidx_media_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||
|
||||
// failOnVersionConflict()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,21 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true" >
|
||||
<!-- <receiver-->
|
||||
<!-- android:name=".NewAppWidget"-->
|
||||
<!-- android:exported="true" >-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
|
||||
<!-- </intent-filter>-->
|
||||
android:usesCleartextTraffic="true"
|
||||
android:largeHeap="true" >
|
||||
|
||||
<!-- <meta-data-->
|
||||
<!-- android:name="android.appwidget.provider"-->
|
||||
<!-- android:resource="@xml/new_app_widget_info" />-->
|
||||
<!-- </receiver>-->
|
||||
<!-- Android app widget -->
|
||||
<receiver
|
||||
android:name="MediaPlayerWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/media_player_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Used by Android Auto -->
|
||||
<meta-data
|
||||
@@ -74,7 +77,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
@@ -83,7 +86,8 @@
|
||||
<service
|
||||
android:name=".player.PlayerNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="true" >
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
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 MediaPlayerWidget : AppWidgetProvider() {
|
||||
val tag = "MediaPlayerWidget"
|
||||
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, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||
|
||||
DbManager.initialize(context)
|
||||
|
||||
DeviceManager.deviceData.lastPlaybackSession?.let {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}")
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, it, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter relevant functionality for when the first widget is created
|
||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Enter relevant functionality for when the last widget is disabled
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||
val tag = "MediaPlayerWidget"
|
||||
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
|
||||
views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI)
|
||||
|
||||
val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD)
|
||||
views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI)
|
||||
|
||||
val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND)
|
||||
views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI)
|
||||
|
||||
// Show/Hide button container
|
||||
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
|
||||
|
||||
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
||||
|
||||
val imageUri = playbackSession?.getCoverUri() ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
super.onResourceReady(resource, transition)
|
||||
}
|
||||
}
|
||||
|
||||
val artist = playbackSession?.displayAuthor ?: "Unknown"
|
||||
views.setTextViewText(R.id.widgetArtistText, artist)
|
||||
|
||||
val title = playbackSession?.displayTitle ?: "Unknown"
|
||||
views.setTextViewText(R.id.widgetMediaTitle, title)
|
||||
|
||||
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)
|
||||
|
||||
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
||||
|
||||
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
|
||||
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
||||
|
||||
// Instruct the widget manager to update the widget
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -126,6 +126,7 @@ class Book(
|
||||
var audioFiles:List<AudioFile>?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var tracks:MutableList<AudioTrack>?,
|
||||
var ebookFile: EBookFile?,
|
||||
var size:Long?,
|
||||
var duration:Double?,
|
||||
var numTracks:Int?
|
||||
@@ -179,7 +180,7 @@ class Book(
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ data class LocalFile(
|
||||
if (mimeType == "video/mp4") return true
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
@JsonIgnore
|
||||
fun isEBookFile():Boolean {
|
||||
return getEBookFormat() != null
|
||||
}
|
||||
@JsonIgnore
|
||||
fun getEBookFormat():String? {
|
||||
if (mimeType == "application/epub+zip") return "epub"
|
||||
if (mimeType == "application/pdf") return "pdf"
|
||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
|
||||
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
|
||||
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -74,6 +88,7 @@ open class LibraryItemWrapper(var id:String) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var deviceId:String,
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var brand:String,
|
||||
@@ -102,7 +117,11 @@ data class DeviceSettings(
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var sleepTimerLength: Long // Time in milliseconds
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
@@ -119,7 +138,11 @@ data class DeviceSettings(
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L // 15 minutes
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,6 +160,7 @@ data class DeviceSettings(
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
@@ -154,8 +178,8 @@ data class DeviceSettings(
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var currentLocalPlaybackSession: PlaybackSession?, // Stored to open up where left off for local media
|
||||
var deviceSettings: DeviceSettings?
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class EBookFile(
|
||||
var ino:String,
|
||||
var metadata:FileMetadata?,
|
||||
var ebookFormat:String,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var contentUrl:String?
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
@@ -41,7 +42,7 @@ class LibraryItem(
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
@@ -46,7 +47,7 @@ class LocalLibraryItem(
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -82,18 +83,21 @@ class LocalLibraryItem(
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||
val currentTime = mediaProgress?.currentTime ?: 0.0
|
||||
|
||||
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
|
||||
|
||||
val mediaMetadata = media.metadata
|
||||
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||
var duration = getDuration()
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
duration = episode.audioTrack?.duration ?: 0.0
|
||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||
}
|
||||
|
||||
val dateNow = System.currentTimeMillis()
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
|
||||
@@ -18,6 +18,7 @@ data class LocalMediaItem(
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var ebookFile:EBookFile?,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
@@ -61,7 +62,7 @@ data class LocalMediaItem(
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import kotlin.math.roundToInt
|
||||
@@ -14,6 +13,8 @@ class LocalMediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?,
|
||||
@@ -33,6 +34,11 @@ class LocalMediaProgress(
|
||||
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun isMatch(mediaProgress:MediaProgress):Boolean {
|
||||
if (episodeId != null) return libraryItemId == mediaProgress.libraryItemId && episodeId == mediaProgress.episodeId
|
||||
return libraryItemId == mediaProgress.libraryItemId
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateIsFinished(finished:Boolean) {
|
||||
@@ -54,11 +60,20 @@ class LocalMediaProgress(
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
this.ebookProgress = ebookProgress
|
||||
this.ebookLocation = ebookLocation
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||
isFinished = serverMediaProgress.isFinished
|
||||
progress = serverMediaProgress.progress
|
||||
currentTime = serverMediaProgress.currentTime
|
||||
ebookProgress = serverMediaProgress.ebookProgress
|
||||
ebookLocation = serverMediaProgress.ebookLocation
|
||||
duration = serverMediaProgress.duration
|
||||
lastUpdate = serverMediaProgress.lastUpdate
|
||||
finishedAt = serverMediaProgress.finishedAt
|
||||
|
||||
@@ -12,6 +12,8 @@ class MediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
|
||||
@@ -6,8 +6,10 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -136,9 +138,9 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@@ -254,7 +256,7 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun syncData(syncData:MediaProgressSyncData) {
|
||||
fun syncData(syncData: MediaProgressSyncData) {
|
||||
timeListening += syncData.timeListened
|
||||
updatedAt = System.currentTimeMillis()
|
||||
currentTime = syncData.currentTime
|
||||
@@ -262,6 +264,6 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.audiobookshelf.app.device
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
@@ -36,6 +39,10 @@ object DeviceManager {
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
||||
}
|
||||
// Initialize auto sleep timer auto rewind added in v0.9.64
|
||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
@@ -46,4 +53,27 @@ object DeviceManager {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
}
|
||||
|
||||
fun checkConnectivity(ctx:Context): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun setLastPlaybackSession(playbackSession:PlaybackSession) {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
dbManager.saveDeviceData(deviceData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class FolderScanner(var ctx: Context) {
|
||||
var mediaItemsUpToDate = 0
|
||||
|
||||
// Search for files in media item folder
|
||||
val foldersFound = df.search(false, DocumentFileType.FOLDER)
|
||||
val foldersFound = df.search(true, DocumentFileType.FOLDER)
|
||||
|
||||
// Match folders found with local library items already saved in db
|
||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
||||
@@ -72,11 +72,16 @@ class FolderScanner(var ctx: Context) {
|
||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||
|
||||
when (scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
|
||||
// Do not scan folders that have no media items and not an existing item already
|
||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||
when (scanLibraryItemFolder(itemFolder, filesInFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +96,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, filesInFolder:List<DocumentFile>, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
val itemFolderName = itemFolder.name ?: ""
|
||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
@@ -105,8 +110,8 @@ class FolderScanner(var ctx: Context) {
|
||||
var startOffset = 0.0
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
var hasEBookFile = false
|
||||
var newEBookFile:EBookFile? = null
|
||||
|
||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
@@ -119,7 +124,6 @@ class FolderScanner(var ctx: Context) {
|
||||
filesInFolder.forEach { file ->
|
||||
val mimeType = file.mimeType ?: ""
|
||||
val filename = file.name ?: ""
|
||||
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
|
||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
||||
@@ -129,7 +133,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||
|
||||
if (isAudio) {
|
||||
if (localFile.isAudioFile()) {
|
||||
val audioTrackToAdd:AudioTrack?
|
||||
|
||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||
@@ -171,6 +175,15 @@ class FolderScanner(var ctx: Context) {
|
||||
startOffset += audioTrackToAdd.duration
|
||||
index++
|
||||
audioTracks.add(audioTrackToAdd)
|
||||
} else if (localFile.isEBookFile()) {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
if (localFolder.mediaType == "book") {
|
||||
hasEBookFile = true
|
||||
if (existingLocalFile == null) {
|
||||
newEBookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
@@ -195,7 +208,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem != null && audioTracks.isEmpty()) {
|
||||
if (existingItem != null && audioTracks.isEmpty() && !hasEBookFile) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||
return ItemScanResult.REMOVED
|
||||
@@ -207,9 +220,9 @@ class FolderScanner(var ctx: Context) {
|
||||
existingItem.updateFromScan(audioTracks,localFiles)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
||||
return ItemScanResult.UPDATED
|
||||
} else if (audioTracks.isNotEmpty()) {
|
||||
} else if (audioTracks.isNotEmpty() || newEBookFile != null) {
|
||||
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,newEBookFile,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
return ItemScanResult.ADDED
|
||||
@@ -254,7 +267,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
// 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"))
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
@@ -271,6 +284,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
@@ -301,6 +315,16 @@ class FolderScanner(var ctx: Context) {
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
@@ -311,8 +335,8 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -347,6 +371,8 @@ class FolderScanner(var ctx: Context) {
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = false,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -378,7 +404,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", "application/octet-stream"))
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||
|
||||
filesFound.forEach {
|
||||
@@ -424,7 +450,7 @@ class FolderScanner(var ctx: Context) {
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||
audioTrack.localFileId == localFile.id
|
||||
audioTrack.localFileId == localFileId
|
||||
}
|
||||
|
||||
if (existingTrack == null) {
|
||||
@@ -443,6 +469,16 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
wasUpdated = true
|
||||
}
|
||||
} else if (localFile.isEBookFile()) {
|
||||
if (localLibraryItem.mediaType == "book") {
|
||||
val existingEbookFile = (localLibraryItem.media as Book).ebookFile
|
||||
if (existingEbookFile == null || existingEbookFile.localFileId != localFileId) {
|
||||
val ebookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanLocalLibraryItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
} else { // Check if cover is empty
|
||||
if (localLibraryItem.coverContentUrl == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||
|
||||
@@ -22,7 +22,7 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
}
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
@@ -237,18 +237,26 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalPlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
|
||||
}
|
||||
fun getLocalPlaybackSession(playbackSessionId:String): PlaybackSession? {
|
||||
return Paper.book("localPlaybackSession").read(playbackSessionId)
|
||||
}
|
||||
|
||||
|
||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
||||
}
|
||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
||||
return Paper.book("mediaItemHistory").read(id)
|
||||
}
|
||||
|
||||
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
|
||||
}
|
||||
fun removePlaybackSession(playbackSessionId:String) {
|
||||
Paper.book("playbackSession").delete(playbackSessionId)
|
||||
}
|
||||
fun getPlaybackSessions():List<PlaybackSession> {
|
||||
val sessions:MutableList<PlaybackSession> = mutableListOf()
|
||||
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
||||
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
||||
sessions.add(it)
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 1
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
@@ -188,7 +189,8 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
if (resultDocFile.name?.endsWith(".m4b.mp3") == true || resultDocFile.name?.endsWith(".m4a.mp3") == true) {
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
@@ -223,23 +225,27 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.metrics.PlaybackSession
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -20,6 +21,9 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
private var isFirstAutoSleepTimer: Boolean = true
|
||||
private var sleepTimerSessionId:String = ""
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
@@ -54,12 +58,13 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
setVolume(1f)
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
@@ -79,13 +84,9 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||
sleepTimerLength = getDuration() - getCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -96,7 +97,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
@@ -106,11 +107,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||
// Start fading out audio
|
||||
val volume = sleepTimeSecondsRemaining / 60F
|
||||
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,12 +122,20 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
}
|
||||
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long {
|
||||
@@ -132,12 +144,21 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
|
||||
if (isAutoSleepTimer) {
|
||||
Log.i(tag, "Disabling auto sleep timer")
|
||||
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
}
|
||||
|
||||
clearSleepTimer()
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
|
||||
// Vibrate when extending sleep timer by shaking
|
||||
private fun vibrate() {
|
||||
// Vibrate when resetting sleep timer
|
||||
private fun vibrateFeedback() {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||
|
||||
val context = playerNotificationService.getContext()
|
||||
val vibrator:Vibrator
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -187,7 +208,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
private fun resetChapterTimer() {
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
@@ -205,6 +226,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (isAutoSleepTimer) {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
}
|
||||
}
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
@@ -212,7 +244,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
@@ -222,14 +254,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
} else if (sleepTimerLength == 0L) {
|
||||
// When navigating to previous chapters make sure this is still the end of the current chapter
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
if (chapterEndTime != sleepTimerEndTime) {
|
||||
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
@@ -264,7 +296,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
@@ -286,7 +318,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun checkAutoSleepTimer() {
|
||||
@@ -303,18 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||
|
||||
// In cases where end time is earlier then start time then we add a day to end time
|
||||
// e.g. start time 22:00 and end time 06:00. End time will be treated as 6am the next day.
|
||||
// e.g. start time 08:00 and end time 22:00. Start and end time will be the same day.
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
|
||||
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
|
||||
// e.g. start time 22:00 and end time 06:00.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||
} else { // Shift end forward a day
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
@@ -322,19 +367,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
}
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent() {
|
||||
checkShouldResetSleepTimer()
|
||||
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||
// Check if the playback session has changed
|
||||
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
|
||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||
checkShouldResetSleepTimer()
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
}
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SyncResult
|
||||
|
||||
object MediaEventManager {
|
||||
const val tag = "MediaEventManager"
|
||||
@@ -69,7 +68,7 @@ object MediaEventManager {
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult:SyncResult?) {
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
@@ -103,8 +102,7 @@ object MediaEventManager {
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.
|
||||
serverConnectionConfigId,
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.util.*
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@@ -32,7 +33,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
|
||||
private var userSettingsPlaybackRate:Float? = null
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
return serverLibraries.find { it.id == id } != null
|
||||
@@ -61,12 +62,41 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
return 1f
|
||||
}
|
||||
|
||||
fun setSavedPlaybackRate(newRate: Float) {
|
||||
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||
val sharedPrefEditor = sharedPrefs.edit()
|
||||
if (sharedPrefs != null) {
|
||||
val userSettingsPref = sharedPrefs.getString("userSettings", null)
|
||||
if (userSettingsPref != null) {
|
||||
try {
|
||||
val userSettings = JSObject(userSettingsPref)
|
||||
// toString().toDouble() to prevent float conversion issues (ex 1.2f becomes 1.2000000476837158d)
|
||||
userSettings.put("playbackRate", newRate.toString().toDouble())
|
||||
sharedPrefEditor.putString("userSettings", userSettings.toString())
|
||||
sharedPrefEditor.apply()
|
||||
userSettingsPlaybackRate = newRate
|
||||
Log.d(tag, "Saved userSettings JSON from Android Auto with playbackRate=$newRate")
|
||||
} catch(je:JSONException) {
|
||||
Log.e(tag, "Failed to save userSettings JSON ${je.localizedMessage}")
|
||||
}
|
||||
} else {
|
||||
// Not sure if this is the best place for this, but if a user has not changed any user settings in the app
|
||||
// the object will not exist yet, could be moved to a centralized place or created on first app load
|
||||
val userSettings = JSONObject()
|
||||
userSettings.put("playbackRate", newRate.toString().toDouble())
|
||||
sharedPrefEditor.putString("userSettings", userSettings.toString())
|
||||
userSettingsPlaybackRate = newRate
|
||||
Log.d(tag, "Created and saved userSettings JSON from Android Auto with playbackRate=$newRate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
@@ -217,7 +247,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
|
||||
coroutineScope {
|
||||
if (!apiHandler.isOnline()) {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.audiobookshelf.app.player
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -6,9 +6,8 @@ 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.data.Podcast
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
@@ -25,7 +24,7 @@ data class SyncResult(
|
||||
var serverSyncMessage:String?
|
||||
)
|
||||
|
||||
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
private val tag = "MediaProgressSync"
|
||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||
|
||||
@@ -40,7 +39,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
@@ -62,8 +61,8 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime")
|
||||
currentPlaybackSession = playbackSession.clone()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
@@ -199,8 +198,6 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
@@ -226,16 +223,25 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val hasNetworkConnection = DeviceManager.checkConnectivity(playerNotificationService)
|
||||
|
||||
// Save playback session to db (server linked sessions only)
|
||||
// Sessions are removed once successfully synced with the server
|
||||
currentPlaybackSession?.let {
|
||||
if (!it.isLocalLibraryItemOnly) {
|
||||
DeviceManager.dbManager.savePlaybackSession(it)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
Log.d(
|
||||
tag,
|
||||
@@ -245,13 +251,14 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||
} 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")
|
||||
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
|
||||
}
|
||||
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
@@ -260,7 +267,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
} else if (shouldSyncServer) {
|
||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
@@ -268,13 +275,14 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.removePlaybackSession(currentSessionId) // Remove session from db
|
||||
} 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")
|
||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
|
||||
}
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
@@ -307,6 +315,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun reset() {
|
||||
currentPlaybackSession = null
|
||||
currentLocalMediaProgress = null
|
||||
|
||||
@@ -32,7 +32,7 @@ data class DownloadItem(
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size > limit) break
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
import com.audiobookshelf.app.data.EBookFile
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -20,6 +21,7 @@ data class DownloadItemPart(
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val ebookFile: EBookFile?,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode: PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
@@ -35,12 +37,12 @@ data class DownloadItemPart(
|
||||
var bytesDownloaded: Long
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
return DownloadItemPart(
|
||||
@@ -53,6 +55,7 @@ data class DownloadItemPart(
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
ebookFile = ebookFile,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class User(
|
||||
val id:String,
|
||||
val username: String,
|
||||
val mediaProgress:List<MediaProgress>
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.exoplayer2.Player
|
||||
@@ -83,7 +84,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
@@ -88,6 +89,25 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
playerNotificationService.seekPlayer(pos)
|
||||
}
|
||||
|
||||
private fun onChangeSpeed() {
|
||||
// cycle to next speed, only contains preset android app options, as each increment needs it's own icon
|
||||
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
|
||||
val mediaManager = playerNotificationService.mediaManager
|
||||
val newSpeed = when (mediaManager.getSavedPlaybackRate()) {
|
||||
in 0.5f..0.7f -> 1.0f
|
||||
in 0.8f..1.0f -> 1.2f
|
||||
in 1.1f..1.2f -> 1.5f
|
||||
in 1.3f..1.5f -> 2.0f
|
||||
in 1.6f..2.0f -> 3.0f
|
||||
in 2.1f..3.0f -> 0.5f
|
||||
// anything set above 3 (can happen in the android app) will be reset to 1
|
||||
else -> 1.0f
|
||||
}
|
||||
mediaManager.setSavedPlaybackRate(newSpeed)
|
||||
playerNotificationService.setPlaybackSpeed(newSpeed)
|
||||
playerNotificationService.clientEventEmitter?.onPlaybackSpeedChanged(newSpeed)
|
||||
}
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
@@ -138,17 +158,46 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
|
||||
|
||||
// Widget button intent is only sending the action down event
|
||||
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")
|
||||
|
||||
// TODO: Play/pause event sent from widget when app is closed. Currently the service gets destroyed before anything can happen
|
||||
// if (playerNotificationService.currentPlaybackSession == null && DeviceManager.deviceData.lastPlaybackSession != null) {
|
||||
// Log.i(tag, "No playback session but had one in the db")
|
||||
//
|
||||
// val connectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == DeviceManager.deviceData.lastPlaybackSession?.serverConnectionConfigId }
|
||||
// connectionConfig?.let {
|
||||
// Log.i(tag, "Setting playback session from db $it")
|
||||
// DeviceManager.serverConnectionConfig = it
|
||||
//
|
||||
// playerNotificationService.currentPlaybackSession = DeviceManager.deviceData.lastPlaybackSession
|
||||
// playerNotificationService.startNewPlaybackSession()
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
|
||||
if (playerNotificationService.mPlayer.isPlaying) {
|
||||
playerNotificationService.pause()
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
} else {
|
||||
playerNotificationService.play()
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Fast Forward")
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Rewind")
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,27 +232,9 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||
if (playerNotificationService.mPlayer.isPlaying) {
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
} else {
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||
return false
|
||||
@@ -248,6 +279,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
|
||||
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
|
||||
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_for
|
||||
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
||||
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
|
||||
const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed";
|
||||
|
||||
const val PLAYMETHOD_DIRECTPLAY = 0
|
||||
const val PLAYMETHOD_DIRECTSTREAM = 1
|
||||
|
||||
@@ -7,7 +7,7 @@ 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
|
||||
//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
|
||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||
var tag = "PlayerListener"
|
||||
@@ -74,12 +74,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||
}
|
||||
|
||||
// TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress
|
||||
// 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
|
||||
}
|
||||
// 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)
|
||||
@@ -92,12 +93,15 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
// Start/stop progress sync interval
|
||||
if (isPlaying) {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent()
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) }
|
||||
playbackSession?.let {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id)
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
|
||||
playerNotificationService.mediaProgressSyncer.play(it)
|
||||
}
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.pause {
|
||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
|
||||
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
|
||||
var tag = "PlayerNotificationListener"
|
||||
|
||||
companion object {
|
||||
var isForegroundService = false
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
onGoing: Boolean) {
|
||||
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
PlayerNotificationService.isClosed = false
|
||||
playerNotificationService.startForeground(notificationId, notification)
|
||||
if (onGoing && !isForegroundService) {
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
PlayerNotificationService.isClosed = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
playerNotificationService.startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
} else {
|
||||
playerNotificationService.startForeground(notificationId, notification)
|
||||
}
|
||||
isForegroundService = true
|
||||
} else {
|
||||
Log.d(tag, "Notification posted $notificationId, not starting foreground - onGoing=$onGoing | isForegroundService=$isForegroundService")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(
|
||||
@@ -39,5 +55,6 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
||||
PlayerNotificationService.isSwitchingPlayer = false
|
||||
}
|
||||
}
|
||||
isForegroundService = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.*
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -14,6 +17,7 @@ import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
@@ -21,6 +25,8 @@ import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -34,6 +40,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.managers.SleepTimerManager
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
@@ -59,6 +66,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var isStarted = false
|
||||
var isClosed = false
|
||||
var isUnmeteredNetwork = false
|
||||
var hasNetworkConnectivity = false // Not 100% reliable has internet
|
||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||
}
|
||||
|
||||
@@ -70,7 +78,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
fun onPlayingUpdate(isPlaying: Boolean)
|
||||
fun onMetadata(metadata: PlaybackMetadata)
|
||||
fun onSleepTimerEnded(currentPosition: Long)
|
||||
fun onSleepTimerSet(sleepTimeRemaining: Int)
|
||||
fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean)
|
||||
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
|
||||
fun onPlaybackFailed(errorMessage:String)
|
||||
fun onMediaPlayerChanged(mediaPlayer:String)
|
||||
@@ -78,6 +86,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
fun onProgressSyncSuccess()
|
||||
fun onNetworkMeteredChanged(isUnmetered:Boolean)
|
||||
fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory)
|
||||
fun onPlaybackSpeedChanged(playbackSpeed:Float)
|
||||
}
|
||||
private val binder = LocalBinder()
|
||||
|
||||
@@ -97,7 +106,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var castPlayer:CastPlayer? = null
|
||||
|
||||
lateinit var sleepTimerManager:SleepTimerManager
|
||||
lateinit var mediaProgressSyncer:MediaProgressSyncer
|
||||
lateinit var mediaProgressSyncer: MediaProgressSyncer
|
||||
|
||||
private var notificationId = 10
|
||||
private var channelId = "audiobookshelf_channel"
|
||||
@@ -167,13 +176,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
Log.d(tag, "onDestroy")
|
||||
isStarted = false
|
||||
isClosed = true
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(this)
|
||||
|
||||
playerNotificationManager.setPlayer(null)
|
||||
mPlayer.release()
|
||||
castPlayer?.release()
|
||||
mediaSession.release()
|
||||
mediaProgressSyncer.reset()
|
||||
Log.d(tag, "onDestroy")
|
||||
isStarted = false
|
||||
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -182,6 +194,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.d(tag, "onTaskRemoved")
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@@ -193,8 +206,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
// 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)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
|
||||
@@ -256,7 +269,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||
playerNotificationManager.setUseRewindActionInCompactView(true)
|
||||
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
|
||||
playerNotificationManager.setSmallIcon(R.drawable.icon_monochrome)
|
||||
|
||||
// Unknown action
|
||||
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
@@ -378,23 +391,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
isClosed = false
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
)
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
|
||||
customActionProviders.addAll(listOf(
|
||||
SkipBackwardCustomActionProvider(),
|
||||
SkipForwardCustomActionProvider(),
|
||||
))
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
// Set actions on Android Auto like jump forward/backward
|
||||
setMediaSessionConnectorCustomActions(playbackSession)
|
||||
|
||||
playbackSession.mediaPlayer = getMediaPlayer()
|
||||
|
||||
@@ -412,10 +417,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
currentPlaybackSession = playbackSession
|
||||
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||
DeviceManager.setLastPlaybackSession(playbackSession) // Save playback session to use when app is closed
|
||||
|
||||
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||
// Notify client
|
||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||
|
||||
// Update widget
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(this)
|
||||
|
||||
if (mediaItems.isEmpty()) {
|
||||
Log.e(tag, "Invalid playback session no media items to play")
|
||||
currentPlaybackSession = null
|
||||
@@ -472,6 +482,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
ChangePlaybackSpeedCustomActionProvider() // Will be pushed to far left
|
||||
)
|
||||
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
|
||||
customActionProviders.addAll(listOf(
|
||||
SkipBackwardCustomActionProvider(),
|
||||
SkipForwardCustomActionProvider(),
|
||||
))
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
}
|
||||
|
||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||
// On error and was attempting to direct play - fallback to transcode
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
@@ -481,16 +507,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||
val episodeId = playbackSession.episodeId
|
||||
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)
|
||||
mediaProgressSyncer.stop(false) {
|
||||
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(true)
|
||||
@@ -530,17 +559,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
fun startNewPlaybackSession() {
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
Log.i(tag, "Starting new playback session for ${playbackSession.displayTitle}")
|
||||
|
||||
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)
|
||||
mediaProgressSyncer.stop(false) {
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to start new playback session")
|
||||
} else {
|
||||
Log.d(tag, "New playback session response from server with session id ${it.id} for \"${it.displayTitle}\"")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,7 +696,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
if (currentPlaybackSession == null) return true
|
||||
|
||||
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
|
||||
if (!DeviceManager.checkConnectivity(ctx) || playbackSession.isLocalLibraryItemOnly) {
|
||||
return true // carry on
|
||||
}
|
||||
|
||||
@@ -703,7 +737,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
// Should already be playing
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
|
||||
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||
mediaProgressSyncer.play(playbackSession)
|
||||
}
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
@@ -733,7 +769,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
mediaProgressSyncer.play(it)
|
||||
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||
mediaProgressSyncer.play(playbackSession)
|
||||
}
|
||||
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
@@ -781,6 +820,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
currentPlaybackSession?.currentTime = timeToSeek / 1000.0
|
||||
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
||||
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
||||
Log.d(tag, "seekPlayer seekTo $newWindowIndex | $newTimeOffset")
|
||||
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
||||
} else {
|
||||
currentPlayer.seekTo(timeToSeek)
|
||||
@@ -812,7 +852,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
mediaManager.userSettingsPlaybackRate = speed
|
||||
currentPlayer.setPlaybackSpeed(speed)
|
||||
|
||||
// Refresh Android Auto actions
|
||||
mediaProgressSyncer.currentPlaybackSession?.let {
|
||||
setMediaSessionConnectorCustomActions(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun closePlayback(calledOnError:Boolean? = false) {
|
||||
@@ -849,7 +895,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||
}
|
||||
|
||||
fun getDeviceInfo(): DeviceInfo {
|
||||
@SuppressLint("HardwareIds")
|
||||
private fun getDeviceInfo(): DeviceInfo {
|
||||
/* EXAMPLE
|
||||
manufacturer: Google
|
||||
model: Pixel 6
|
||||
@@ -857,7 +904,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
sdkVersion: 32
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
@@ -881,7 +929,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
//
|
||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||
//
|
||||
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 VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", "com.audiobookshelf.app.debug", "com.android.systemui", 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 LIBRARIES_ROOT = "__LIBRARIES__"
|
||||
@@ -1086,10 +1134,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
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)
|
||||
|
||||
isUnmeteredNetwork = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
hasNetworkConnectivity = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
Log.i(tag, "Network capabilities changed. hasNetworkConnectivity=$hasNetworkConnectivity | isUnmeteredNetwork=$isUnmeteredNetwork")
|
||||
clientEventEmitter?.onNetworkMeteredChanged(isUnmeteredNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,5 +1209,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
).build()
|
||||
}
|
||||
}
|
||||
|
||||
inner class ChangePlaybackSpeedCustomActionProvider : CustomActionProvider {
|
||||
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||
/*
|
||||
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||
responsible to reacting to a custom action.
|
||||
*/
|
||||
}
|
||||
|
||||
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||
val playbackRate = mediaManager.getSavedPlaybackRate()
|
||||
|
||||
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
|
||||
val drawable: Int = when (playbackRate) {
|
||||
in 0.5f..0.7f -> R.drawable.ic_play_speed_0_5x
|
||||
in 0.8f..1.0f -> R.drawable.ic_play_speed_1_0x
|
||||
in 1.1f..1.3f -> R.drawable.ic_play_speed_1_2x
|
||||
in 1.4f..1.7f -> R.drawable.ic_play_speed_1_5x
|
||||
in 1.8f..2.4f -> R.drawable.ic_play_speed_2_0x
|
||||
in 2.5f..3.0f -> R.drawable.ic_play_speed_3_0x
|
||||
// anything set above 3 will be show the 3x to save from creating 100 icons
|
||||
else -> R.drawable.ic_play_speed_3_0x
|
||||
}
|
||||
val customActionExtras = Bundle()
|
||||
customActionExtras.putFloat("speed", playbackRate)
|
||||
return PlaybackStateCompat.CustomAction.Builder(
|
||||
CUSTOM_ACTION_CHANGE_SPEED,
|
||||
getContext().getString(R.string.action_change_speed),
|
||||
drawable
|
||||
)
|
||||
.setExtras(customActionExtras)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
initCastManager()
|
||||
try {
|
||||
initCastManager()
|
||||
} catch(e:Exception) {
|
||||
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
|
||||
}
|
||||
|
||||
val foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
@@ -63,8 +67,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
emit("onSleepTimerEnded", currentPosition)
|
||||
}
|
||||
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
|
||||
emit("onSleepTimerSet", sleepTimeRemaining)
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
||||
val ret = JSObject()
|
||||
ret.put("value", sleepTimeRemaining)
|
||||
ret.put("isAuto", isAutoSleepTimer)
|
||||
notifyListeners("onSleepTimerSet", ret)
|
||||
}
|
||||
|
||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
||||
@@ -94,6 +101,10 @@ class AbsAudioPlayer : Plugin() {
|
||||
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
|
||||
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||
}
|
||||
|
||||
override fun onPlaybackSpeedChanged(playbackSpeed:Float) {
|
||||
emit("onPlaybackSpeedChanged", playbackSpeed)
|
||||
}
|
||||
})
|
||||
|
||||
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
|
||||
@@ -169,7 +180,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||
val startTimeOverride = call.getDouble("startTime")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
@@ -330,7 +341,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
@@ -371,7 +383,9 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun cancelSleepTimer(call: PluginCall) {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@@ -216,13 +216,25 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun syncLocalMediaProgressWithServer(call:PluginCall) {
|
||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
Log.e(tag, "syncLocalMediaProgressWithServer not connected to server")
|
||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
apiHandler.syncMediaProgress {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
|
||||
apiHandler.syncLocalMediaProgressForUser {
|
||||
Log.d(tag, "Finished syncing local media progress for user")
|
||||
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
|
||||
if (savedSessions.isNotEmpty()) {
|
||||
apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg ->
|
||||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +265,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -333,6 +347,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = if (isFinished) 1.0 else 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = isFinished,
|
||||
ebookLocation = null,
|
||||
ebookProgress = null,
|
||||
lastUpdate = currentTime,
|
||||
startedAt = if (isFinished) currentTime else 0L,
|
||||
finishedAt = if (isFinished) currentTime else null,
|
||||
@@ -377,6 +393,55 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalEbookProgress(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
val ebookLocation = call.getString("ebookLocation", "").toString()
|
||||
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
|
||||
|
||||
val localMediaProgressId = localLibraryItemId
|
||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
|
||||
|
||||
val book = localLibraryItem.media as Book
|
||||
|
||||
localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = null,
|
||||
duration = book.duration ?: 0.0,
|
||||
progress = 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = false,
|
||||
ebookLocation = ebookLocation,
|
||||
ebookProgress = ebookProgress,
|
||||
lastUpdate = System.currentTimeMillis(),
|
||||
startedAt = 0L,
|
||||
finishedAt = null,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = null)
|
||||
} else {
|
||||
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
|
||||
}
|
||||
|
||||
// Save local media progress locally
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
||||
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalTrackOrder(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
@@ -51,7 +50,6 @@ class AbsDownloader : Plugin() {
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -143,6 +141,28 @@ class AbsDownloader : Plugin() {
|
||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
val book = libraryItem.media as Book
|
||||
book.ebookFile?.let { ebookFile ->
|
||||
val fileSize = ebookFile.metadata?.size ?: 0
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(ebookFile.metadata?.relPath ?: "")}"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
// Create download item part for each audio track
|
||||
tracks.forEach { audioTrack ->
|
||||
val fileSize = audioTrack.metadata?.size ?: 0
|
||||
@@ -163,7 +183,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -188,7 +208,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -217,7 +237,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
@@ -233,7 +253,7 @@ class AbsDownloader : Plugin() {
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@CapacitorPlugin(name = "AbsFileSystem")
|
||||
class AbsFileSystem : Plugin() {
|
||||
@@ -226,8 +227,37 @@ class AbsFileSystem : Plugin() {
|
||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||
|
||||
var subfolderPathToDelete = ""
|
||||
|
||||
// Check if should delete subfolder
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
val success = docfile?.delete() == true
|
||||
|
||||
if (subfolderPathToDelete != "") {
|
||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||
docfilesub?.delete()
|
||||
}
|
||||
|
||||
if (success) {
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -28,14 +28,14 @@ class ApiHandler(var ctx:Context) {
|
||||
|
||||
private var defaultClient = OkHttpClient()
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>, val serverProgressUpdates:List<MediaProgress>)
|
||||
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int, var serverProgressUpdates:List<MediaProgress>)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
|
||||
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
|
||||
@@ -45,18 +45,20 @@ class ApiHandler(var ctx:Context) {
|
||||
makeRequest(request, httpClient, cb)
|
||||
}
|
||||
|
||||
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
private 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 requestUrl = "${address}$endpoint"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
|
||||
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
||||
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().patch(requestBody)
|
||||
@@ -65,31 +67,7 @@ class ApiHandler(var ctx:Context) {
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
||||
fun isOnline(): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isUsingCellularData(): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
||||
}
|
||||
|
||||
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||
private 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) {
|
||||
@@ -135,6 +113,18 @@ class ApiHandler(var ctx:Context) {
|
||||
})
|
||||
}
|
||||
|
||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getCurrentUser Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val user = jacksonMapper.readValue<User>(it.toString())
|
||||
cb(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraries(cb: (List<Library>) -> Unit) {
|
||||
val mapper = jacksonMapper
|
||||
getRequest("/api/libraries", null,null) {
|
||||
@@ -251,59 +241,6 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
|
||||
if (!isOnline()) {
|
||||
Log.d(tag, "Error not online")
|
||||
cb(LocalMediaProgressSyncResultsPayload(0,0,0, mutableListOf()))
|
||||
return
|
||||
}
|
||||
|
||||
// Get all local media progress connected to items on the current connected server
|
||||
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
|
||||
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
||||
}
|
||||
|
||||
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0, mutableListOf())
|
||||
|
||||
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, 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())
|
||||
|
||||
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
|
||||
localSyncResultsPayload.serverProgressUpdates = progressSyncResponsePayload.serverProgressUpdates
|
||||
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
|
||||
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
|
||||
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
|
||||
// Update all local media progress
|
||||
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Local progress updated. Received from server sync local API request")
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
}
|
||||
}
|
||||
|
||||
progressSyncResponsePayload.serverProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Server progress updated. Received from server sync local API request")
|
||||
}
|
||||
}
|
||||
|
||||
cb(localSyncResultsPayload)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "No local media progress to sync")
|
||||
cb(localSyncResultsPayload)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) {
|
||||
Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload")
|
||||
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||
@@ -375,4 +312,65 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||
response.results.forEach { localSessionSyncResult ->
|
||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||
if (localSessionSyncResult.progressSynced == true) {
|
||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||
MediaEventManager.saveEvent(session, syncResult)
|
||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
cb(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
||||
// Get all local media progress for this server
|
||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
if (allLocalMediaProgress.isEmpty()) {
|
||||
Log.d(tag, "No local media progress to sync")
|
||||
return cb()
|
||||
}
|
||||
|
||||
getCurrentUser { _user ->
|
||||
_user?.let { user->
|
||||
// Compare server user progress with local progress
|
||||
user.mediaProgress.forEach { mediaProgress ->
|
||||
// Get matching local media progress
|
||||
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
||||
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
||||
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||
// Patch ebook progress to server
|
||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
||||
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
||||
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
||||
patchRequest(endpoint,updatePayload) {
|
||||
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M7,-50.96875Q7,-69.375,10.6875,-80.84375Q14.390625,-92.328125,21.671875,-98.15625Q28.953125,-104,40,-104Q48.140625,-104,54.53125,-100.625Q60.9375,-97.265625,64.6875,-90.921875Q68.4375,-84.59375,70.71875,-75.5Q73,-66.40625,73,-50.96875Q73,-32.703125,69.34375,-21.21875Q65.6875,-9.75,58.390625,-3.875Q51.109375,2,40,2Q25.359375,2,17.015625,-8.265625Q7,-21.71875,7,-50.96875ZM20,-51Q20,-25.828125,25.765625,-17.90625Q31.53125,-10,40,-10Q48.46875,-10,54.234375,-17.953125Q60,-25.90625,60,-51Q60,-75.734375,54.359375,-83.859375Q48.71875,-92,39.859375,-92Q31.40625,-92,26.09375,-84.828125Q20,-75.671875,20,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M192,-13L192,0L124,0Q123.859375,-4.75,125.421875,-9.125Q127.921875,-16.359375,133.4375,-23.25Q138.96875,-30.140625,149.64062,-38.6875Q166.21875,-52.625,172.10938,-60.28125Q178,-67.9375,178,-75.015625Q178,-82.171875,172.70312,-87.078125Q167.40625,-92,158.875,-92Q149.875,-92,144.46875,-86.953125Q139.0625,-81.921875,139,-73L126,-75Q127.328125,-89.15625,135.95312,-96.578125Q144.59375,-104,159.15625,-104Q173.85938,-104,182.42188,-95.71875Q191,-87.4375,191,-75.203125Q191,-68.96875,188.59375,-62.953125Q186.20312,-56.9375,180.64062,-50.28125Q175.09375,-43.640625,161.73438,-32.375Q150.35938,-23.03125,146.96875,-19.6875Q143.59375,-16.359375,141.39062,-13L192,-13Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M72,-13L72,-0L4,-0Q3.859375,-4.75,5.421875,-9.125Q7.921875,-16.359375,13.4375,-23.25Q18.96875,-30.140625,29.640625,-38.6875Q46.21875,-52.625,52.109375,-60.28125Q58,-67.9375,58,-75.015625Q58,-82.171875,52.703125,-87.078125Q47.40625,-92,38.875,-92Q29.875,-92,24.46875,-86.953125Q19.0625,-81.921875,19,-73L6,-75Q7.328125,-89.15625,15.953125,-96.578125Q24.59375,-104,39.15625,-104Q53.859375,-104,62.421875,-95.71875Q71,-87.4375,71,-75.203125Q71,-68.96875,68.59375,-62.953125Q66.203125,-56.9375,60.640625,-50.28125Q55.09375,-43.640625,41.734375,-32.375Q30.359375,-23.03125,26.96875,-19.6875Q23.59375,-16.359375,21.390625,-13L72,-13Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M7,-27L20,-29Q22.109375,-18.90625,26.90625,-14.453125Q31.71875,-10,39.484375,-10Q48.609375,-10,54.296875,-16Q60,-22.015625,60,-30.90625Q60,-39.375,54.5,-44.890625Q49,-50.40625,40.515625,-50.40625Q37.046875,-50.40625,31.890625,-49L33.25,-61Q34.46875,-60.859375,35.203125,-60.859375Q42.8125,-60.859375,48.90625,-64.796875Q55,-68.75,55,-76.953125Q55,-83.46875,50.484375,-87.734375Q45.984375,-92,38.84375,-92Q31.765625,-92,27.046875,-87.75Q22.34375,-83.5,21,-75L8,-77Q10.28125,-89.859375,18.34375,-96.921875Q26.40625,-104,38.40625,-104Q46.6875,-104,53.65625,-100.390625Q60.625,-96.796875,64.3125,-90.578125Q68,-84.359375,68,-77.359375Q68,-70.71875,64.40625,-65.265625Q60.828125,-59.8125,53.796875,-56.59375Q62.9375,-54.453125,67.96875,-47.6875Q73,-40.9375,73,-30.796875Q73,-17.078125,63.375,-7.53125Q53.75,2,39.03125,2Q25.765625,2,17,-6.078125Q8.234375,-14.15625,7,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 311 B |
|
After Width: | Height: | Size: 303 B |
|
After Width: | Height: | Size: 315 B |
|
After Width: | Height: | Size: 338 B |
|
After Width: | Height: | Size: 346 B |
|
Before Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 664 B |
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 583 B |
|
After Width: | Height: | Size: 599 B |
|
After Width: | Height: | Size: 684 B |
|
After Width: | Height: | Size: 712 B |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 895 B |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 892 B |
|
After Width: | Height: | Size: 1011 B |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="862dp"
|
||||
android:height="1024dp"
|
||||
android:viewportWidth="862"
|
||||
android:viewportHeight="1024">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M855,504.4c-5.3,-4.4 -13.5,-10.9 -24.7,-18.6v-86.5c0,-220.5 -178.8,-399.3 -399.3,-399.3v0c-220.5,0 -399.3,178.8 -399.3,399.3v86.5c-11.1,7.7 -19.4,14.2 -24.7,18.6 -4.4,3.7 -7.1,9.1 -7.1,15.2 0,0 0,0 0,0v-0,103.6c0,0 0,0 0,0 0,6.1 2.8,11.6 7.1,15.2l0,0c12.3,10.3 40.6,31.8 84.7,53.8v10.1c0,27.2 17.5,49.2 39.1,49.2v0c21.6,0 39.1,-22 39.1,-49.2v-248.5c0,-27.2 -17.5,-49.2 -39.1,-49.2v0c-20.7,0 -37.7,20.2 -39,45.8l-0.1,0v-51.2c0,-187.4 151.9,-339.3 339.3,-339.3v0c187.4,0 339.3,151.9 339.3,339.3v51.2l-0.1,-0c-1.4,-25.6 -18.3,-45.8 -39,-45.8v0c-21.6,0 -39.1,22 -39.1,49.2v248.5c0,27.2 17.5,49.2 39.1,49.2v0c21.6,0 39.1,-22 39.1,-49.2v-10.1c44,-22.1 72.3,-43.6 84.7,-53.8 4.4,-3.7 7.1,-9.1 7.1,-15.2 0,-0 0,-0 0,-0v0,-103.6c0,-0 0,-0 0,-0 0,-6.1 -2.8,-11.6 -7.1,-15.2l-0,-0zM291.8,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM213.5,439.4h107.8v11.3h-107.8zM455.5,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM377.2,439.4h107.8v11.3h-107.8zM619.1,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM540.8,439.4h107.8v11.3h-107.8zM114.1,952.7h633.9c19.7,0 35.6,15.9 35.6,35.6v0c0,19.7 -15.9,35.6 -35.6,35.6h-633.9c-19.7,0 -35.6,-15.9 -35.6,-35.6v-0c0,-19.7 15.9,-35.6 35.6,-35.6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,108 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/widgetBackground"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="horizontal"
|
||||
android:theme="@style/AppTheme.AppWidgetContainer"
|
||||
style="@style/Widget.Android.AppWidget.Container">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:background="?android:attr/colorAccent"
|
||||
android:theme="@style/MediaPlayerWidgetTheme"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetAlbumArt"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/icon" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetArtistText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="2"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:gravity="bottom"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="Artist"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetMediaTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="2"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:gravity="top"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="Title"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widgetButtonContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="3"
|
||||
android:orientation="horizontal"
|
||||
android:background="?android:attr/colorAccent"
|
||||
android:theme="@style/MediaPlayerWidgetTheme">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetRewindButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="8dp"
|
||||
android:src="@drawable/exo_icon_rewind"
|
||||
style="@style/Widget.Android.AppWidget.InnerView"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetPlayPauseButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="6dp"
|
||||
android:src="@drawable/ic_media_play_dark" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetFastForwardButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="8dp"
|
||||
android:src="@drawable/exo_icon_fastforward" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/tinyCornerIcon"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_margin="6dp"
|
||||
android:adjustViewBounds ="true"
|
||||
android:src="@drawable/icon_monochrome" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<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,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Having themes.xml for night-v31 because of the priority order of the resource qualifiers.
|
||||
-->
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
|
||||
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
|
||||
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<resources>
|
||||
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:id">@android:id/background</item>
|
||||
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||
<item name="android:background">@drawable/app_widget_background</item>
|
||||
<item name="android:clipToOutline">true</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>-->
|
||||
<!-- <item name="android:textColor">?android:attr/textColorPrimary</item>-->
|
||||
<item name="android:clipToOutline">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Having themes.xml for v31 variant because @android:dimen/system_app_widget_background_radius
|
||||
and @android:dimen/system_app_widget_internal_padding requires API level 31
|
||||
-->
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
|
||||
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
|
||||
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,7 +1,9 @@
|
||||
<resources>
|
||||
<declare-styleable name="AppWidgetAttrs">
|
||||
<attr name="appWidgetPadding" format="dimension" />
|
||||
<attr name="appWidgetInnerRadius" format="dimension" />
|
||||
<attr name="appWidgetRadius" format="dimension" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
<declare-styleable name="AppWidgetAttrs">
|
||||
<attr name="appWidgetPadding" format="dimension" />
|
||||
<attr name="appWidgetInnerRadius" format="dimension" />
|
||||
<attr name="appWidgetRadius" format="dimension" />
|
||||
<attr name="backgroundRadius" format="dimension" />
|
||||
<attr name="appWidgetViewPadding" format="dimension" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
|
||||
@@ -10,4 +10,5 @@
|
||||
<string name="action_jump_backward">Jump Backward</string>
|
||||
<string name="action_skip_forward">Skip Forward</string>
|
||||
<string name="action_skip_backward">Skip Backward</string>
|
||||
<string name="action_change_speed">Change Playback Speed</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
<!-- 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.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- <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="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||
<item name="android:background">?android:attr/colorBackground</item>
|
||||
<item name="android:textColor">?android:attr/textColorPrimary</item>
|
||||
</style>
|
||||
<style name="MediaPlayerWidgetTheme" parent="Theme.AppCompat.DayNight">
|
||||
<!-- These colors match the vue pages -->
|
||||
<item name="android:colorBackground">#232323</item>
|
||||
<item name="android:colorAccent">#373838</item>
|
||||
<item name="android:textColor">#ffffff</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>-->
|
||||
<item name="android:padding">0dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,17 +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>
|
||||
<!--
|
||||
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
|
||||
<!-- Radius of the outer bound of widgets to make the rounded corners -->
|
||||
<item name="appWidgetRadius">0dp</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>
|
||||
<item name="appWidgetInnerRadius">0dp</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>
|
||||
<style name="AppTheme.AppWidgetContainer" parent="AppTheme.AppWidgetContainerParent">
|
||||
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
|
||||
<item name="appWidgetPadding">0dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/app_widget_description"
|
||||
android:initialKeyguardLayout="@layout/media_player_widget"
|
||||
android:initialLayout="@layout/media_player_widget"
|
||||
android:minWidth="275dp"
|
||||
android:minHeight="50dp"
|
||||
android:previewImage="@drawable/media_player_widget_preview"
|
||||
android:previewLayout="@layout/media_player_widget"
|
||||
android:resizeMode="horizontal"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -1,12 +0,0 @@
|
||||
<?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"
|
||||
/>
|
||||
@@ -14,7 +14,7 @@ ext {
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_car_version = '1.0.0-alpha7'
|
||||
androidx_core_ktx_version = '1.7.0'
|
||||
androidx_media_version = '1.5.0'
|
||||
androidx_media_version = '1.6.0'
|
||||
androidx_preference_version = '1.1.1'
|
||||
androidx_test_runner_version = '1.3.0'
|
||||
arch_lifecycle_version = '2.2.0'
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
-webkit-touch-callout: auto;
|
||||
-webkit-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
}
|
||||
@@ -20,9 +33,9 @@ body {
|
||||
}
|
||||
|
||||
#content.playerOpen {
|
||||
height: calc(100% - 164px);
|
||||
min-height: calc(100% - 164px);
|
||||
max-height: calc(100% - 164px);
|
||||
height: calc(100% - 184px);
|
||||
min-height: calc(100% - 184px);
|
||||
max-height: calc(100% - 184px);
|
||||
}
|
||||
|
||||
#bookshelf {
|
||||
@@ -102,4 +115,32 @@ Bookshelf Label
|
||||
background-position: center;
|
||||
opacity: 1;
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
.episode-subtitle {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-4 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
@@ -48,25 +48,6 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div v-if="user && currentLibrary">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
<p class="text-smleading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,27 +12,27 @@
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative">
|
||||
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative rounded-full">
|
||||
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<div class="w-full h-full flex justify-center">
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
|
||||
</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">
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && isServerItem && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
@@ -60,13 +60,13 @@
|
||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
@@ -80,20 +80,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-3">
|
||||
<div class="flex pb-0.5">
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||
<div class="flex pointer-events-none">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="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 ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi || !showFullscreen }" />
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 -top-1 rounded-full bg-gray-200 absolute pointer-events-auto" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,6 +140,7 @@ export default {
|
||||
onMetadataListener: null,
|
||||
onProgressSyncFailing: null,
|
||||
onProgressSyncSuccess: null,
|
||||
onPlaybackSpeedChangedListener: null,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
@@ -150,8 +148,10 @@ export default {
|
||||
useTotalTrack: true,
|
||||
lockUi: false,
|
||||
isLoading: false,
|
||||
touchTrackStart: false,
|
||||
dragPercent: 0,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
draggingTouchStartTime: 0,
|
||||
draggingCurrentTime: 0,
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
@@ -286,6 +286,7 @@ export default {
|
||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||
},
|
||||
title() {
|
||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||
},
|
||||
@@ -317,17 +318,20 @@ export default {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
currentTimePretty() {
|
||||
return this.$secondsToTimestamp(this.currentTime)
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
|
||||
},
|
||||
timeRemaining() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||
}
|
||||
return this.totalTimeRemaining
|
||||
},
|
||||
totalTimeRemaining() {
|
||||
return (this.totalDuration - this.currentTime) / this.currentPlaybackRate
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return (this.totalDuration - currentTimeToUse) / this.currentPlaybackRate
|
||||
},
|
||||
totalTimeRemainingPretty() {
|
||||
if (this.totalTimeRemaining < 0) {
|
||||
@@ -341,10 +345,6 @@ export default {
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
timeLeftInChapter() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentTime
|
||||
},
|
||||
sleepTimeRemainingPretty() {
|
||||
if (!this.sleepTimeRemaining) return '0s'
|
||||
var secondsRemaining = Math.round(this.sleepTimeRemaining)
|
||||
@@ -369,6 +369,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChaptersBtn() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChapterModal = true
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
@@ -391,11 +395,6 @@ export default {
|
||||
this.showFullscreen = false
|
||||
}
|
||||
},
|
||||
async touchstartTrack(e) {
|
||||
await this.$hapticsImpact()
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
this.touchTrackStart = true
|
||||
},
|
||||
async selectChapter(chapter) {
|
||||
await this.$hapticsImpact()
|
||||
this.seek(chapter.start)
|
||||
@@ -449,9 +448,9 @@ export default {
|
||||
this.$emit('showSleepTimer')
|
||||
},
|
||||
async setPlaybackSpeed(speed) {
|
||||
await this.$hapticsImpact()
|
||||
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
||||
this.currentPlaybackRate = speed
|
||||
this.updateTimestamp()
|
||||
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
|
||||
},
|
||||
restart() {
|
||||
@@ -502,19 +501,18 @@ export default {
|
||||
}
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
const ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
var currTimeStr = ''
|
||||
|
||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = Math.max(0, this.currentTime - this.currentChapter.start)
|
||||
currTimeStr = this.$secondsToTimestamp(currChapTime)
|
||||
} else {
|
||||
currTimeStr = this.$secondsToTimestamp(this.currentTime)
|
||||
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
||||
}
|
||||
ts.innerText = currTimeStr
|
||||
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
@@ -536,22 +534,24 @@ export default {
|
||||
},
|
||||
updateTrack() {
|
||||
// Update progress track UI
|
||||
let percentDone = this.currentTime / this.totalDuration
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
let percentDone = currentTimeToUse / this.totalDuration
|
||||
const totalPercentDone = percentDone
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = this.currentTime - this.currentChapter.start
|
||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
}
|
||||
|
||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
|
||||
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
@@ -580,27 +580,15 @@ export default {
|
||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||
}
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.isLoading || this.lockUi) return
|
||||
if (!this.showFullscreen) {
|
||||
// Track not clickable on mini-player
|
||||
return
|
||||
}
|
||||
if (e) e.stopPropagation()
|
||||
async touchstartCursor(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = 0
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
time = perc * this.currentChapterDuration + this.currentChapter.start
|
||||
} else {
|
||||
time = perc * this.totalDuration
|
||||
}
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.seek(time)
|
||||
await this.$hapticsImpact()
|
||||
this.isDraggingCursor = true
|
||||
this.draggingTouchStartX = e.touches[0].pageX
|
||||
this.draggingTouchStartTime = this.currentTime
|
||||
this.draggingCurrentTime = this.currentTime
|
||||
this.updateTrack()
|
||||
},
|
||||
async playPauseClick() {
|
||||
await this.$hapticsImpact()
|
||||
@@ -653,24 +641,11 @@ export default {
|
||||
touchend(e) {
|
||||
if (!e.changedTouches) 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
|
||||
if (this.isDraggingCursor) {
|
||||
if (this.draggingCurrentTime !== this.currentTime) {
|
||||
this.seek(this.draggingCurrentTime)
|
||||
}
|
||||
this.seek(seekToTime)
|
||||
|
||||
if (this.$refs.draggingTrack) {
|
||||
this.$refs.draggingTrack.style.width = '0px'
|
||||
}
|
||||
this.touchTrackStart = false
|
||||
this.isDraggingCursor = false
|
||||
} else if (this.showFullscreen) {
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
@@ -682,29 +657,24 @@ export default {
|
||||
}
|
||||
},
|
||||
touchmove(e) {
|
||||
if (!this.touchTrackStart) return
|
||||
if (!this.isDraggingCursor || !e.touches) 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'
|
||||
const distanceMoved = e.touches[0].pageX - this.draggingTouchStartX
|
||||
let duration = this.totalDuration
|
||||
let minTime = 0
|
||||
let maxTime = duration
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
duration = this.currentChapterDuration
|
||||
minTime = this.currentChapter.start
|
||||
maxTime = minTime + duration
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const timePerPixel = duration / this.trackWidth
|
||||
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
|
||||
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
},
|
||||
async clickMenuAction(action) {
|
||||
await this.$hapticsImpact()
|
||||
@@ -783,8 +753,6 @@ export default {
|
||||
|
||||
console.log('received metadata update', data)
|
||||
|
||||
if (data.currentRate && data.currentRate > 0) this.playbackSpeed = data.currentRate
|
||||
|
||||
this.timeupdate()
|
||||
},
|
||||
// When a playback session is started the native android/ios will send the session
|
||||
@@ -815,6 +783,11 @@ export default {
|
||||
this.$toast.error(`Playback Failed: ${errorMessage}`)
|
||||
this.endPlayback()
|
||||
},
|
||||
onPlaybackSpeedChanged(data) {
|
||||
if (!data.value || isNaN(data.value)) return
|
||||
this.currentPlaybackRate = Number(data.value)
|
||||
this.updateTimestamp()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
@@ -826,6 +799,7 @@ export default {
|
||||
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(() => {
|
||||
@@ -846,10 +820,10 @@ export default {
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 24 + coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
this.showFullscreen = false
|
||||
this.collapseFullscreen()
|
||||
},
|
||||
showProgressSyncIsFailing() {
|
||||
this.syncStatus = this.$constants.SyncStatus.FAILED
|
||||
@@ -901,6 +875,7 @@ export default {
|
||||
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
||||
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
||||
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
|
||||
if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove()
|
||||
clearInterval(this.playInterval)
|
||||
}
|
||||
}
|
||||
@@ -912,11 +887,11 @@ export default {
|
||||
--cover-image-height: 0px;
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
--title-author-left-offset-collapsed: 70px;
|
||||
--title-author-left-offset-collapsed: 80px;
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
height: 100px;
|
||||
height: 120px;
|
||||
}
|
||||
.fullscreen .playerContainer {
|
||||
height: 200px;
|
||||
@@ -931,21 +906,22 @@ export default {
|
||||
#playerTrack {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: margin;
|
||||
bottom: 20px;
|
||||
bottom: 35px;
|
||||
}
|
||||
.fullscreen #playerTrack {
|
||||
top: 20px;
|
||||
bottom: unset;
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
bottom: 48px;
|
||||
left: 12px;
|
||||
bottom: 68px;
|
||||
left: 24px;
|
||||
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;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.total-track {
|
||||
@@ -960,7 +936,7 @@ export default {
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
bottom: 56px;
|
||||
bottom: 76px;
|
||||
left: var(--title-author-left-offset-collapsed);
|
||||
text-align: left;
|
||||
}
|
||||
@@ -986,20 +962,18 @@ export default {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.fullscreen .title-author-texts .title-text {
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.5rem);
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.3rem);
|
||||
}
|
||||
.fullscreen .title-author-texts .author-text {
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1.1rem);
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
|
||||
}
|
||||
|
||||
#playerControls {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: width, bottom;
|
||||
height: 48px;
|
||||
width: 140px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
bottom: 50px;
|
||||
width: 128px;
|
||||
padding-right: 24px;
|
||||
bottom: 70px;
|
||||
}
|
||||
#playerControls .jump-icon {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -1016,8 +990,7 @@ export default {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
margin: 0px 14px;
|
||||
/* padding: 8px; */
|
||||
margin: 0px 7px;
|
||||
}
|
||||
#playerControls .play-btn .material-icons {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -1032,27 +1005,28 @@ export default {
|
||||
width: var(--cover-image-width);
|
||||
left: calc(50% - (calc(var(--cover-image-width)) / 2));
|
||||
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fullscreen #playerControls {
|
||||
width: 100%;
|
||||
bottom: 94px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
bottom: 78px;
|
||||
left: 0;
|
||||
}
|
||||
.fullscreen #playerControls .jump-icon {
|
||||
margin: 0px 18px;
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
.fullscreen #playerControls .next-icon {
|
||||
margin: 0px 20px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.fullscreen #playerControls .play-btn {
|
||||
/* padding: 16px; */
|
||||
height: 65px;
|
||||
width: 65px;
|
||||
min-width: 65px;
|
||||
min-height: 65px;
|
||||
margin: 0px 26px;
|
||||
}
|
||||
.fullscreen #playerControls .play-btn .material-icons {
|
||||
font-size: 2.1rem;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
|
||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :is-auto="isAutoSleepTimer" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="currentTime" :library-item-id="serverLibraryItemId" @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
isSleepTimerRunning: false,
|
||||
sleepTimerEndTime: 0,
|
||||
sleepTimeRemaining: 0,
|
||||
isAutoSleepTimer: false,
|
||||
onLocalMediaProgressUpdateListener: null,
|
||||
onSleepTimerEndedListener: null,
|
||||
onSleepTimerSetListener: null,
|
||||
@@ -42,6 +43,9 @@ export default {
|
||||
bookmarks() {
|
||||
if (!this.serverLibraryItemId) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -62,8 +66,9 @@ export default {
|
||||
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
|
||||
}
|
||||
},
|
||||
onSleepTimerSet({ value: sleepTimeRemaining }) {
|
||||
console.log('SLEEP TIMER SET', sleepTimeRemaining)
|
||||
onSleepTimerSet(payload) {
|
||||
const { value: sleepTimeRemaining, isAuto } = payload
|
||||
console.log('SLEEP TIMER SET', JSON.stringify(payload))
|
||||
if (sleepTimeRemaining === 0) {
|
||||
console.log('Sleep timer canceled')
|
||||
this.isSleepTimerRunning = false
|
||||
@@ -71,6 +76,7 @@ export default {
|
||||
this.isSleepTimerRunning = true
|
||||
}
|
||||
|
||||
this.isAutoSleepTimer = !!isAuto
|
||||
this.sleepTimeRemaining = sleepTimeRemaining
|
||||
},
|
||||
showSleepTimer() {
|
||||
@@ -185,6 +191,7 @@ export default {
|
||||
const libraryItemId = payload.libraryItemId
|
||||
const episodeId = payload.episodeId
|
||||
const startTime = payload.startTime
|
||||
const startWhenReady = !payload.paused
|
||||
|
||||
// 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
|
||||
@@ -207,6 +214,8 @@ export default {
|
||||
if (startTime !== undefined && startTime !== null) {
|
||||
// seek to start time
|
||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||
} else if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -220,7 +229,7 @@ export default {
|
||||
}
|
||||
|
||||
console.log('Called playLibraryItem', libraryItemId)
|
||||
const preparePayload = { libraryItemId, episodeId, playWhenReady: true, playbackRate }
|
||||
const preparePayload = { libraryItemId, episodeId, playWhenReady: startWhenReady, playbackRate }
|
||||
if (startTime !== undefined && startTime !== null) preparePayload.startTime = startTime
|
||||
AbsAudioPlayer.prepareLibraryItem(preparePayload)
|
||||
.then((data) => {
|
||||
@@ -265,9 +274,13 @@ export default {
|
||||
this.notifyOnReady()
|
||||
},
|
||||
notifyOnReady() {
|
||||
// TODO: iOS opens last active playback session on app launch. Should be consistent with Android
|
||||
if (!this.isIos) return
|
||||
|
||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||
if (this.settingsLoaded && this.isReady) {
|
||||
if (this.settingsLoaded && this.isReady && this.$store.state.isFirstAudioLoad) {
|
||||
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
||||
AbsAudioPlayer.onReady()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ export default {
|
||||
watch: {
|
||||
showBookshelfListView(newVal) {
|
||||
this.resetEntities()
|
||||
},
|
||||
seriesId() {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -85,6 +88,12 @@ export default {
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('mobileFilterBy')
|
||||
},
|
||||
collapseSeries() {
|
||||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||
},
|
||||
collapseBookSeries() {
|
||||
return this.$store.getters['user/getUserSetting']('collapseBookSeries')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
@@ -356,6 +365,9 @@ export default {
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||
if (this.collapseBookSeries) {
|
||||
searchParams.set('collapseseries', 1)
|
||||
}
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
@@ -452,6 +464,12 @@ export default {
|
||||
this.libraryItemUpdated(ab)
|
||||
})
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(() => {
|
||||
console.log('LazyBookshelf Screen orientation change')
|
||||
this.resetEntities()
|
||||
}, 50)
|
||||
},
|
||||
initListeners() {
|
||||
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
if (bookshelf) {
|
||||
@@ -466,6 +484,13 @@ export default {
|
||||
this.$socket.$on('item_removed', this.libraryItemRemoved)
|
||||
this.$socket.$on('items_updated', this.libraryItemsUpdated)
|
||||
this.$socket.$on('items_added', this.libraryItemsAdded)
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
},
|
||||
removeListeners() {
|
||||
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
@@ -481,6 +506,13 @@ export default {
|
||||
this.$socket.$off('item_removed', this.libraryItemRemoved)
|
||||
this.$socket.$off('items_updated', this.libraryItemsUpdated)
|
||||
this.$socket.$off('items_added', this.libraryItemsAdded)
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<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 v-if="!altViewEnabled" class="absolute text-center categoryPlacardtransform 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>
|
||||
|
||||
@@ -7,26 +7,6 @@
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex h-full px-1 overflow-hidden shadow-sm">
|
||||
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
|
||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||
<path
|
||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ author }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,11 +78,14 @@ export default {
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,15 +10,20 @@
|
||||
<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' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
@@ -26,16 +31,16 @@
|
||||
<!-- Placeholder Cover Title & Author -->
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70">
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70" @click.stop="playEpisode">
|
||||
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -57,8 +62,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
@@ -216,7 +223,7 @@ export default {
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
return ''
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
@@ -226,22 +233,36 @@ export default {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
}
|
||||
return this.title
|
||||
seriesSequenceList() {
|
||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||
},
|
||||
displayAuthor() {
|
||||
libraryItemIdsInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||
},
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.collapsedSeries) return ''
|
||||
if (this.isPodcast) return this.author
|
||||
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.collapsedSeries) return null
|
||||
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 === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -255,8 +276,13 @@ export default {
|
||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
@@ -401,38 +427,40 @@ export default {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
async playEpisode() {
|
||||
await this.$hapticsImpact()
|
||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
eventBus.$emit('pause-item')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.localLibraryItem) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
},
|
||||
async clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
} else if (this.recentEpisode) {
|
||||
await this.$hapticsImpact()
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
eventBus.$emit('pause-item')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.localLibraryItem) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
} else {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
const router = this.$router || this.$nuxt.$router
|
||||
if (router) {
|
||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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(240, width) + 'px' }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||
</div>
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(160, 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>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
<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="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<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 v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-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>
|
||||
@@ -53,6 +55,27 @@ export default {
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
.map((libraryItem) => {
|
||||
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||
})
|
||||
.filter((p) => !!p)
|
||||
},
|
||||
seriesBooksFinished() {
|
||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||
},
|
||||
hasSeriesBookInProgress() {
|
||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||
},
|
||||
seriesPercentInProgress() {
|
||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: height + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<div class="w-full h-full relative bg-bg">
|
||||
<div class="w-full h-full relative" :class="{ 'bg-bg': !noBg }">
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="showCoverBg && hasCover ? 'object-contain' : 'object-fill'" />
|
||||
<img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="(showCoverBg && hasCover) || noBg ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<div class="absolute top-2 right-2">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
@@ -18,17 +18,17 @@
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
<p class="text-centertext-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book truncate leading-none origin-center" style="color: rgb(247 223 187); font-size: 0.8rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ titleCleaned }}</p>
|
||||
<p class="text-centertruncate leading-none origin-center" style="color: rgb(247 223 187); font-size: 0.8rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center font-book truncate leading-none origin-center" style="color: rgb(247 223 187); opacity: 0.75; font-size: 0.6rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ authorCleaned }}</p>
|
||||
<p class="text-centertruncate leading-none origin-center" style="color: rgb(247 223 187); opacity: 0.75; font-size: 0.6rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,7 +48,8 @@ export default {
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
downloadCover: String,
|
||||
raw: Boolean
|
||||
raw: Boolean,
|
||||
noBg: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -156,7 +157,7 @@ export default {
|
||||
this.$nextTick(() => {
|
||||
this.imageReady = true
|
||||
})
|
||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
if (!this.noBg && this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
|
||||
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||