mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-10 05:48:38 +02:00
Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00bed39733 | ||
|
|
2048795c92 | ||
|
|
8c9bd53934 | ||
|
|
01178d00bf | ||
|
|
7541e4b660 | ||
|
|
2366c938b5 | ||
|
|
59480ad114 | ||
|
|
ce7779607f | ||
|
|
04bc74babe | ||
|
|
ac2b674ba5 | ||
|
|
e53901e7a4 | ||
|
|
3ede41f496 | ||
|
|
0adeaaa04d | ||
|
|
57d1fbfa83 | ||
|
|
27bbe40874 | ||
|
|
60da6093f3 | ||
|
|
259c9cef20 | ||
|
|
6d61b2acaf | ||
|
|
72f687b079 | ||
|
|
0ce3c8bcbd | ||
|
|
f9621d2f60 | ||
|
|
e634132a39 | ||
|
|
59b428100b | ||
|
|
36e0c91e27 | ||
|
|
03225957aa | ||
|
|
77f70e48de | ||
|
|
cbdc3513c7 | ||
|
|
394479bea0 | ||
|
|
b9592e46a7 | ||
|
|
5810a83c66 | ||
|
|
389c7d23ca | ||
|
|
defc7d216a | ||
|
|
852702112a | ||
|
|
dd91bc6667 | ||
|
|
cdbbd56a58 | ||
|
|
d6a1eba657 | ||
|
|
da899bfddd | ||
|
|
b7b746fb2a | ||
|
|
c8053355df | ||
|
|
1ddb6eca00 | ||
|
|
f42c624cba | ||
|
|
f8fbeef9b7 | ||
|
|
46b82a81ce | ||
|
|
48342e5bd3 | ||
|
|
147f89f870 | ||
|
|
8b39ba9c92 | ||
|
|
13d32dc1f7 | ||
|
|
0d1312e497 | ||
|
|
ff4f8324e7 | ||
|
|
b4bf10d409 | ||
|
|
ae37861f7d | ||
|
|
1da03cdd65 | ||
|
|
a7443d0992 | ||
|
|
5a953464ab | ||
|
|
953bde5a42 | ||
|
|
481d0d0285 | ||
|
|
7b3a005ad6 | ||
|
|
4f9d341f69 | ||
|
|
9e7d1d1617 | ||
|
|
4f03c2a35d | ||
|
|
db3c5396e9 | ||
|
|
455af239a1 | ||
|
|
d8bc26f5f8 | ||
|
|
543ac209e4 | ||
|
|
c8b5cefeb5 | ||
|
|
f1411011e6 | ||
|
|
f3964dda17 | ||
|
|
d207e88e18 | ||
|
|
2643e98fc0 | ||
|
|
410648e316 | ||
|
|
373221703d | ||
|
|
fbcb8620f9 | ||
|
|
a8ac4970cc | ||
|
|
195e33ae32 | ||
|
|
9acf695802 | ||
|
|
e6aaccfc74 | ||
|
|
87c74fe78b | ||
|
|
7dc319c1bd | ||
|
|
c093548b71 | ||
|
|
b067a2d3d5 | ||
|
|
a9ae7bfdb8 | ||
|
|
9dcf22614c | ||
|
|
c7564d439e | ||
|
|
157dc01673 | ||
|
|
cce6e1d0ab | ||
|
|
b935cb5cf1 | ||
|
|
9ab37ac525 | ||
|
|
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 |
@@ -21,6 +21,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: "temurin"
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
- name: install dependencies
|
- name: install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: "temurin"
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
- name: install dependencies
|
- name: install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
||||||
|
|
||||||
namespace 'com.audiobookshelf.app'
|
namespace 'com.audiobookshelf.app'
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
@@ -35,8 +33,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 94
|
versionCode 97
|
||||||
versionName "0.9.63-beta"
|
versionName "0.9.66-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -65,12 +63,11 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
configurations.all {
|
configurations.configureEach {
|
||||||
resolutionStrategy {
|
resolutionStrategy {
|
||||||
force("com.google.android.gms:play-services-base:18.0.1")
|
force("com.google.android.gms:play-services-base:18.0.1")
|
||||||
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
||||||
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
||||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
|
|
||||||
force("androidx.media:media:$androidx_media_version")
|
force("androidx.media:media:$androidx_media_version")
|
||||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||||
@@ -83,12 +80,9 @@ dependencies {
|
|||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
|
||||||
// testImplementation "junit:junit:$junitVersion"
|
|
||||||
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
||||||
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||||
@@ -116,7 +110,6 @@ dependencies {
|
|||||||
|
|
||||||
// Glide for images
|
// Glide for images
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
|
||||||
|
|
||||||
// Paper NoSQL Db
|
// Paper NoSQL Db
|
||||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ android {
|
|||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-clipboard')
|
||||||
implementation project(':capacitor-dialog')
|
implementation project(':capacitor-dialog')
|
||||||
implementation project(':capacitor-haptics')
|
implementation project(':capacitor-haptics')
|
||||||
implementation project(':capacitor-network')
|
implementation project(':capacitor-network')
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true" >
|
android:usesCleartextTraffic="true"
|
||||||
|
android:largeHeap="true" >
|
||||||
|
|
||||||
<!-- Android app widget -->
|
<!-- Android app widget -->
|
||||||
<receiver
|
<receiver
|
||||||
@@ -91,6 +92,16 @@
|
|||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:grantUriPermissions="true"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -2,5 +2,10 @@
|
|||||||
"appId": "com.audiobookshelf.app",
|
"appId": "com.audiobookshelf.app",
|
||||||
"appName": "audiobookshelf-app",
|
"appName": "audiobookshelf-app",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"bundledWebRuntime": false
|
"bundledWebRuntime": false,
|
||||||
|
"plugins": {
|
||||||
|
"CapacitorHttp": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
"pkg": "@capacitor/app",
|
"pkg": "@capacitor/app",
|
||||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/clipboard",
|
||||||
|
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/dialog",
|
"pkg": "@capacitor/dialog",
|
||||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import android.util.Log
|
|||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
import com.anggrayudi.storage.SimpleStorage
|
||||||
import com.anggrayudi.storage.SimpleStorageHelper
|
import com.anggrayudi.storage.SimpleStorageHelper
|
||||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
|
||||||
import com.audiobookshelf.app.managers.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||||
|
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||||
import com.getcapacitor.BridgeActivity
|
import com.getcapacitor.BridgeActivity
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import android.widget.RemoteViews
|
|||||||
import androidx.media.session.MediaButtonReceiver
|
import androidx.media.session.MediaButtonReceiver
|
||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
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.Glide
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||||
@@ -27,16 +26,15 @@ import com.bumptech.glide.request.transition.Transition
|
|||||||
*/
|
*/
|
||||||
class MediaPlayerWidget : AppWidgetProvider() {
|
class MediaPlayerWidget : AppWidgetProvider() {
|
||||||
val tag = "MediaPlayerWidget"
|
val tag = "MediaPlayerWidget"
|
||||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
// There may be multiple widgets active, so update all of them
|
Log.d(tag, "onUpdate $appWidgetIds")
|
||||||
for (appWidgetId in appWidgetIds) {
|
}
|
||||||
updateAppWidget(context, appWidgetManager, appWidgetId, null, false, PlayerNotificationService.isClosed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEnabled(context: Context) {
|
override fun onEnabled(context: Context) {
|
||||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||||
|
|
||||||
|
DbManager.initialize(context)
|
||||||
|
|
||||||
DeviceManager.deviceData.lastPlaybackSession?.let {
|
DeviceManager.deviceData.lastPlaybackSession?.let {
|
||||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
@@ -48,32 +46,14 @@ class MediaPlayerWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enter relevant functionality for when the first widget is created
|
// Enter relevant functionality for when the first widget is created
|
||||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
DeviceManager.initializeWidgetUpdater(context)
|
||||||
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) {
|
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||||
val tag = "MediaPlayerWidget"
|
val tag = "MediaPlayerWidget"
|
||||||
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||||
|
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
|
||||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||||
@@ -96,8 +76,8 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
|||||||
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
|
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
|
||||||
|
|
||||||
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
||||||
|
|
||||||
val imageUri = playbackSession?.getCoverUri() ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
super.onResourceReady(resource, transition)
|
super.onResourceReady(resource, transition)
|
||||||
@@ -115,7 +95,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
|||||||
|
|
||||||
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
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
|
val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
|
||||||
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
||||||
|
|
||||||
// Instruct the widget manager to update the widget
|
// Instruct the widget manager to update the widget
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun removeAudioTrack(localFileId:String) { }
|
open fun removeAudioTrack(localFileId:String) { }
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ class Podcast(
|
|||||||
audioTracks.forEach { at ->
|
audioTracks.forEach { at ->
|
||||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||||
val localEpisodeId = "local_ep_" + at.localFileId
|
val localEpisodeId = "local_ep_" + at.localFileId
|
||||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId)
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ class Podcast(
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||||
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
||||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId)
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -96,7 +96,7 @@ class Podcast(
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
val localEpisodeId = "local_ep_" + episode.id
|
val localEpisodeId = "local_ep_" + episode.id
|
||||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id, localEpisodeId)
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,0, episode.id, localEpisodeId)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -126,6 +126,7 @@ class Book(
|
|||||||
var audioFiles:List<AudioFile>?,
|
var audioFiles:List<AudioFile>?,
|
||||||
var chapters:List<BookChapter>?,
|
var chapters:List<BookChapter>?,
|
||||||
var tracks:MutableList<AudioTrack>?,
|
var tracks:MutableList<AudioTrack>?,
|
||||||
|
var ebookFile: EBookFile?,
|
||||||
var size:Long?,
|
var size:Long?,
|
||||||
var duration:Double?,
|
var duration:Double?,
|
||||||
var numTracks:Int?
|
var numTracks:Int?
|
||||||
@@ -179,7 +180,7 @@ class Book(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getLocalCopy(): Book {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +190,7 @@ class Book(
|
|||||||
JsonSubTypes.Type(BookMetadata::class),
|
JsonSubTypes.Type(BookMetadata::class),
|
||||||
JsonSubTypes.Type(PodcastMetadata::class)
|
JsonSubTypes.Type(PodcastMetadata::class)
|
||||||
)
|
)
|
||||||
open class MediaTypeMetadata(var title:String) {
|
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getAuthorDisplayName():String { return "Unknown" }
|
open fun getAuthorDisplayName():String { return "Unknown" }
|
||||||
}
|
}
|
||||||
@@ -208,13 +209,13 @@ class BookMetadata(
|
|||||||
var isbn:String?,
|
var isbn:String?,
|
||||||
var asin:String?,
|
var asin:String?,
|
||||||
var language:String?,
|
var language:String?,
|
||||||
var explicit:Boolean,
|
explicit:Boolean,
|
||||||
// In toJSONExpanded
|
// In toJSONExpanded
|
||||||
var authorName:String?,
|
var authorName:String?,
|
||||||
var authorNameLF:String?,
|
var authorNameLF:String?,
|
||||||
var narratorName:String?,
|
var narratorName:String?,
|
||||||
var seriesName:String?
|
var seriesName:String?
|
||||||
) : MediaTypeMetadata(title) {
|
) : MediaTypeMetadata(title, explicit) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||||
}
|
}
|
||||||
@@ -224,8 +225,9 @@ class PodcastMetadata(
|
|||||||
title:String,
|
title:String,
|
||||||
var author:String?,
|
var author:String?,
|
||||||
var feedUrl:String?,
|
var feedUrl:String?,
|
||||||
var genres:MutableList<String>
|
var genres:MutableList<String>,
|
||||||
) : MediaTypeMetadata(title) {
|
explicit:Boolean
|
||||||
|
) : MediaTypeMetadata(title, explicit) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
||||||
}
|
}
|
||||||
@@ -250,15 +252,16 @@ data class PodcastEpisode(
|
|||||||
var publishedAt:Long?,
|
var publishedAt:Long?,
|
||||||
var audioFile:AudioFile?,
|
var audioFile:AudioFile?,
|
||||||
var audioTrack:AudioTrack?,
|
var audioTrack:AudioTrack?,
|
||||||
|
var chapters:List<BookChapter>?,
|
||||||
var duration:Double?,
|
var duration:Double?,
|
||||||
var size:Long?,
|
var size:Long?,
|
||||||
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||||
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
val coverUri = if (libraryItem is LocalLibraryItem) {
|
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||||
libraryItem.getCoverUri()
|
libraryItem.getCoverUri(ctx)
|
||||||
} else {
|
} else {
|
||||||
(libraryItem as LibraryItem).getCoverUri()
|
(libraryItem as LibraryItem).getCoverUri()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ data class LocalFile(
|
|||||||
if (mimeType == "video/mp4") return true
|
if (mimeType == "video/mp4") return true
|
||||||
return mimeType?.startsWith("audio") == 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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -69,14 +83,14 @@ data class LocalFolder(
|
|||||||
)
|
)
|
||||||
open class LibraryItemWrapper(var id:String) {
|
open class LibraryItemWrapper(var id:String) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
|
var deviceId:String,
|
||||||
var manufacturer:String,
|
var manufacturer:String,
|
||||||
var model:String,
|
var model:String,
|
||||||
var brand:String,
|
|
||||||
var sdkVersion:Int,
|
var sdkVersion:Int,
|
||||||
var clientVersion: String
|
var clientVersion: String
|
||||||
)
|
)
|
||||||
@@ -95,6 +109,7 @@ data class DeviceSettings(
|
|||||||
var enableAltView:Boolean,
|
var enableAltView:Boolean,
|
||||||
var jumpBackwardsTime:Int,
|
var jumpBackwardsTime:Int,
|
||||||
var jumpForwardTime:Int,
|
var jumpForwardTime:Int,
|
||||||
|
var enableMp3IndexSeeking:Boolean,
|
||||||
var disableShakeToResetSleepTimer:Boolean,
|
var disableShakeToResetSleepTimer:Boolean,
|
||||||
var shakeSensitivity: ShakeSensitivitySetting,
|
var shakeSensitivity: ShakeSensitivitySetting,
|
||||||
var lockOrientation: LockOrientationSetting,
|
var lockOrientation: LockOrientationSetting,
|
||||||
@@ -102,6 +117,8 @@ data class DeviceSettings(
|
|||||||
var autoSleepTimer: Boolean,
|
var autoSleepTimer: Boolean,
|
||||||
var autoSleepTimerStartTime: String,
|
var autoSleepTimerStartTime: String,
|
||||||
var autoSleepTimerEndTime: String,
|
var autoSleepTimerEndTime: String,
|
||||||
|
var autoSleepTimerAutoRewind: Boolean,
|
||||||
|
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||||
var sleepTimerLength: Long, // Time in milliseconds
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
var disableSleepTimerFadeOut: Boolean,
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
var disableSleepTimerResetFeedback: Boolean
|
var disableSleepTimerResetFeedback: Boolean
|
||||||
@@ -114,6 +131,7 @@ data class DeviceSettings(
|
|||||||
enableAltView = true,
|
enableAltView = true,
|
||||||
jumpBackwardsTime = 10,
|
jumpBackwardsTime = 10,
|
||||||
jumpForwardTime = 10,
|
jumpForwardTime = 10,
|
||||||
|
enableMp3IndexSeeking = false,
|
||||||
disableShakeToResetSleepTimer = false,
|
disableShakeToResetSleepTimer = false,
|
||||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||||
lockOrientation = LockOrientationSetting.NONE,
|
lockOrientation = LockOrientationSetting.NONE,
|
||||||
@@ -122,6 +140,8 @@ data class DeviceSettings(
|
|||||||
autoSleepTimerStartTime = "22:00",
|
autoSleepTimerStartTime = "22:00",
|
||||||
autoSleepTimerEndTime = "06:00",
|
autoSleepTimerEndTime = "06:00",
|
||||||
sleepTimerLength = 900000L, // 15 minutes
|
sleepTimerLength = 900000L, // 15 minutes
|
||||||
|
autoSleepTimerAutoRewind = false,
|
||||||
|
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||||
disableSleepTimerFadeOut = false,
|
disableSleepTimerFadeOut = false,
|
||||||
disableSleepTimerResetFeedback = false
|
disableSleepTimerResetFeedback = false
|
||||||
)
|
)
|
||||||
@@ -141,6 +161,7 @@ data class DeviceSettings(
|
|||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||||
|
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||||
|
|||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -58,7 +58,7 @@ class LibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
val extras = Bundle()
|
val extras = Bundle()
|
||||||
|
|
||||||
if (localLibraryItemId != null) {
|
if (localLibraryItemId != null) {
|
||||||
@@ -90,6 +90,10 @@ class LibraryItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.metadata.explicit) {
|
||||||
|
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||||
|
}
|
||||||
|
|
||||||
val mediaId = localLibraryItemId ?: id
|
val mediaId = localLibraryItemId ?: id
|
||||||
return MediaDescriptionCompat.Builder()
|
return MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(mediaId)
|
.setMediaId(mediaId)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.os.Bundle
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
@@ -46,7 +48,10 @@ class LocalLibraryItem(
|
|||||||
val isPodcast get() = mediaType == "podcast"
|
val isPodcast get() = mediaType == "podcast"
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCoverUri(): Uri {
|
fun getCoverUri(ctx:Context): Uri {
|
||||||
|
if (coverContentUrl?.startsWith("file:") == true) {
|
||||||
|
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
|
||||||
|
}
|
||||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
|
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||||
val localEpisodeId = episode?.id
|
val localEpisodeId = episode?.id
|
||||||
val sessionId = "play_local_${UUID.randomUUID()}"
|
val sessionId = "play_local_${UUID.randomUUID()}"
|
||||||
|
|
||||||
@@ -85,19 +90,20 @@ class LocalLibraryItem(
|
|||||||
|
|
||||||
|
|
||||||
val mediaMetadata = media.metadata
|
val mediaMetadata = media.metadata
|
||||||
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||||
val authorName = mediaMetadata.getAuthorDisplayName()
|
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||||
val displayTitle = episode?.title ?: mediaMetadata.title
|
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||||
var duration = getDuration()
|
var duration = getDuration()
|
||||||
if (episode != null) { // Get podcast episode audio track
|
if (episode != null) { // Get podcast episode audio track
|
||||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||||
|
chapters = episode.chapters
|
||||||
duration = episode.audioTrack?.duration ?: 0.0
|
duration = episode.audioTrack?.duration ?: 0.0
|
||||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val dateNow = System.currentTimeMillis()
|
val dateNow = System.currentTimeMillis()
|
||||||
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")
|
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@@ -106,18 +112,16 @@ class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
|
||||||
val coverUri = getCoverUri()
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
var bitmap:Bitmap? = null
|
var bitmap:Bitmap? = null
|
||||||
if (coverContentUrl != null) {
|
if (coverContentUrl != null) {
|
||||||
ctx?.let {
|
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
|
} else {
|
||||||
} else {
|
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
|
ImageDecoder.decodeBitmap(source)
|
||||||
ImageDecoder.decodeBitmap(source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +152,10 @@ class LocalLibraryItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.metadata.explicit) {
|
||||||
|
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||||
|
}
|
||||||
|
|
||||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ data class LocalMediaItem(
|
|||||||
var basePath:String,
|
var basePath:String,
|
||||||
var absolutePath:String,
|
var absolutePath:String,
|
||||||
var audioTracks:MutableList<AudioTrack>,
|
var audioTracks:MutableList<AudioTrack>,
|
||||||
|
var ebookFile:EBookFile?,
|
||||||
var localFiles:MutableList<LocalFile>,
|
var localFiles:MutableList<LocalFile>,
|
||||||
var coverContentUrl:String?,
|
var coverContentUrl:String?,
|
||||||
var coverAbsolutePath:String?
|
var coverAbsolutePath:String?
|
||||||
@@ -42,7 +43,7 @@ data class LocalMediaItem(
|
|||||||
return if (mediaType == "book") {
|
return if (mediaType == "book") {
|
||||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
||||||
} else {
|
} else {
|
||||||
PodcastMetadata(name,null,null, mutableListOf())
|
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ data class LocalMediaItem(
|
|||||||
val mediaMetadata = getMediaMetadata()
|
val mediaMetadata = getMediaMetadata()
|
||||||
if (mediaType == "book") {
|
if (mediaType == "book") {
|
||||||
val chapters = getAudiobookChapters()
|
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)
|
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||||
} else {
|
} else {
|
||||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class LocalMediaProgress(
|
|||||||
progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
currentTime:Double,
|
currentTime:Double,
|
||||||
isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
|
var ebookLocation:String?, // cfi tag
|
||||||
|
var ebookProgress:Double?, // 0 to 1
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?,
|
var finishedAt:Long?,
|
||||||
@@ -58,11 +60,20 @@ class LocalMediaProgress(
|
|||||||
finishedAt = if (isFinished) lastUpdate else null
|
finishedAt = if (isFinished) lastUpdate else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
|
||||||
|
lastUpdate = System.currentTimeMillis()
|
||||||
|
this.ebookProgress = ebookProgress
|
||||||
|
this.ebookLocation = ebookLocation
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||||
isFinished = serverMediaProgress.isFinished
|
isFinished = serverMediaProgress.isFinished
|
||||||
progress = serverMediaProgress.progress
|
progress = serverMediaProgress.progress
|
||||||
currentTime = serverMediaProgress.currentTime
|
currentTime = serverMediaProgress.currentTime
|
||||||
|
ebookProgress = serverMediaProgress.ebookProgress
|
||||||
|
ebookLocation = serverMediaProgress.ebookLocation
|
||||||
duration = serverMediaProgress.duration
|
duration = serverMediaProgress.duration
|
||||||
lastUpdate = serverMediaProgress.lastUpdate
|
lastUpdate = serverMediaProgress.lastUpdate
|
||||||
finishedAt = serverMediaProgress.finishedAt
|
finishedAt = serverMediaProgress.finishedAt
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class MediaProgress(
|
|||||||
progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
currentTime:Double,
|
currentTime:Double,
|
||||||
isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
|
var ebookLocation:String?, // cfi tag
|
||||||
|
var ebookProgress:Double?, // 0 to 1
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?
|
var finishedAt:Long?
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -27,6 +29,7 @@ class PlaybackSession(
|
|||||||
var episodeId:String?,
|
var episodeId:String?,
|
||||||
var mediaType:String,
|
var mediaType:String,
|
||||||
var mediaMetadata:MediaTypeMetadata,
|
var mediaMetadata:MediaTypeMetadata,
|
||||||
|
var deviceInfo:DeviceInfo,
|
||||||
var chapters:List<BookChapter>,
|
var chapters:List<BookChapter>,
|
||||||
var displayTitle: String?,
|
var displayTitle: String?,
|
||||||
var displayAuthor: String?,
|
var displayAuthor: String?,
|
||||||
@@ -137,8 +140,15 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCoverUri(): Uri {
|
fun getCoverUri(ctx:Context): Uri {
|
||||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
|
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
||||||
|
if (coverUri.toString().startsWith("file:")) {
|
||||||
|
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + 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}")
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
@@ -152,6 +162,8 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||||
@@ -162,16 +174,16 @@ class PlaybackSession(
|
|||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||||
|
|
||||||
// Local covers get bitmap
|
// Local covers get bitmap
|
||||||
if (localLibraryItem?.coverContentUrl != null) {
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
} else {
|
} else {
|
||||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
ImageDecoder.decodeBitmap(source)
|
ImageDecoder.decodeBitmap(source)
|
||||||
}
|
}
|
||||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||||
@@ -182,7 +194,9 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
val metadataBuilder = MediaMetadata.Builder()
|
val metadataBuilder = MediaMetadata.Builder()
|
||||||
.setTitle(displayTitle)
|
.setTitle(displayTitle)
|
||||||
.setDisplayTitle(displayTitle)
|
.setDisplayTitle(displayTitle)
|
||||||
@@ -191,20 +205,18 @@ class PlaybackSession(
|
|||||||
.setSubtitle(displayAuthor)
|
.setSubtitle(displayAuthor)
|
||||||
.setAlbumTitle(displayAuthor)
|
.setAlbumTitle(displayAuthor)
|
||||||
.setDescription(displayAuthor)
|
.setDescription(displayAuthor)
|
||||||
.setArtworkUri(getCoverUri())
|
.setArtworkUri(coverUri)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||||
val contentUri = this.getContentUri(audioTrack)
|
|
||||||
metadataBuilder.setMediaUri(contentUri)
|
|
||||||
|
|
||||||
return metadataBuilder.build()
|
return metadataBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaItems():List<MediaItem> {
|
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||||
|
|
||||||
for (audioTrack in audioTracks) {
|
for (audioTrack in audioTracks) {
|
||||||
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||||
val mediaUri = this.getContentUri(audioTrack)
|
val mediaUri = this.getContentUri(audioTrack)
|
||||||
val mimeType = audioTrack.mimeType
|
val mimeType = audioTrack.mimeType
|
||||||
|
|
||||||
@@ -252,7 +264,7 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun clone():PlaybackSession {
|
fun clone():PlaybackSession {
|
||||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@@ -264,6 +276,6 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
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,15 +1,20 @@
|
|||||||
package com.audiobookshelf.app.device
|
package com.audiobookshelf.app.device
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.MediaPlayerWidget
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.managers.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.updateAppWidget
|
||||||
|
|
||||||
interface WidgetEventEmitter {
|
interface WidgetEventEmitter {
|
||||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||||
|
fun onPlayerClosed()
|
||||||
}
|
}
|
||||||
|
|
||||||
object DeviceManager {
|
object DeviceManager {
|
||||||
@@ -39,6 +44,10 @@ object DeviceManager {
|
|||||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
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 {
|
fun getBase64Id(id:String):String {
|
||||||
@@ -72,4 +81,31 @@ object DeviceManager {
|
|||||||
deviceData.lastPlaybackSession = playbackSession
|
deviceData.lastPlaybackSession = playbackSession
|
||||||
dbManager.saveDeviceData(deviceData)
|
dbManager.saveDeviceData(deviceData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun initializeWidgetUpdater(context:Context) {
|
||||||
|
Log.d(tag, "Initializing widget updater")
|
||||||
|
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 onPlayerClosed() {
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
for (widgetId in ids) {
|
||||||
|
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
class FolderScanner(var ctx: Context) {
|
||||||
private val tag = "FolderScanner"
|
private val tag = "FolderScanner"
|
||||||
@@ -72,7 +73,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
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) }
|
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||||
|
|
||||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
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
|
// Do not scan folders that have no media items and not an existing item already
|
||||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||||
@@ -110,6 +111,8 @@ class FolderScanner(var ctx: Context) {
|
|||||||
var startOffset = 0.0
|
var startOffset = 0.0
|
||||||
var coverContentUrl:String? = null
|
var coverContentUrl:String? = null
|
||||||
var coverAbsolutePath:String? = null
|
var coverAbsolutePath:String? = null
|
||||||
|
var hasEBookFile = false
|
||||||
|
var newEBookFile:EBookFile? = null
|
||||||
|
|
||||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||||
@@ -122,7 +125,6 @@ class FolderScanner(var ctx: Context) {
|
|||||||
filesInFolder.forEach { file ->
|
filesInFolder.forEach { file ->
|
||||||
val mimeType = file.mimeType ?: ""
|
val mimeType = file.mimeType ?: ""
|
||||||
val filename = file.name ?: ""
|
val filename = file.name ?: ""
|
||||||
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
|
|
||||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||||
|
|
||||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
val localFileId = DeviceManager.getBase64Id(file.id)
|
||||||
@@ -132,7 +134,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||||
|
|
||||||
if (isAudio) {
|
if (localFile.isAudioFile()) {
|
||||||
val audioTrackToAdd:AudioTrack?
|
val audioTrackToAdd:AudioTrack?
|
||||||
|
|
||||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||||
@@ -174,6 +176,15 @@ class FolderScanner(var ctx: Context) {
|
|||||||
startOffset += audioTrackToAdd.duration
|
startOffset += audioTrackToAdd.duration
|
||||||
index++
|
index++
|
||||||
audioTracks.add(audioTrackToAdd)
|
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 {
|
} else {
|
||||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||||
|
|
||||||
@@ -198,7 +209,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")
|
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||||
return ItemScanResult.REMOVED
|
return ItemScanResult.REMOVED
|
||||||
@@ -210,9 +221,9 @@ class FolderScanner(var ctx: Context) {
|
|||||||
existingItem.updateFromScan(audioTracks,localFiles)
|
existingItem.updateFromScan(audioTracks,localFiles)
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
||||||
return ItemScanResult.UPDATED
|
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")
|
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()
|
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||||
return ItemScanResult.ADDED
|
return ItemScanResult.ADDED
|
||||||
@@ -221,101 +232,126 @@ class FolderScanner(var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan item after download and create local library item
|
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
|
||||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
|
||||||
|
|
||||||
var itemFolderId = ""
|
|
||||||
var itemFolderUrl = ""
|
|
||||||
var itemFolderBasePath = ""
|
|
||||||
var itemFolderAbsolutePath = ""
|
|
||||||
foldersFound.forEach {
|
|
||||||
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
|
||||||
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
|
||||||
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
|
||||||
itemFolderId = it.id
|
|
||||||
itemFolderUrl = it.uri.toString()
|
|
||||||
itemFolderBasePath = it.getBasePath(ctx)
|
|
||||||
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemFolderUrl == "") {
|
|
||||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
|
||||||
return cb(null)
|
|
||||||
}
|
|
||||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
|
||||||
|
|
||||||
if (df == null) {
|
|
||||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
|
||||||
return cb(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
|
||||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
|
||||||
|
|
||||||
// Search for files in media item folder
|
|
||||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
|
||||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
|
||||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
|
||||||
|
|
||||||
var localEpisodeId:String? = null
|
var localEpisodeId:String? = null
|
||||||
var localLibraryItem:LocalLibraryItem?
|
var localLibraryItem:LocalLibraryItem?
|
||||||
if (downloadItem.mediaType == "book") {
|
if (downloadItem.mediaType == "book") {
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||||
} else {
|
} else {
|
||||||
// Lookup or create podcast local library item
|
// Lookup or create podcast local library item
|
||||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
if (localLibraryItem == null) {
|
if (localLibraryItem == null) {
|
||||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||||
|
var foundEBookFile = false
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
||||||
itemPart.filename == docFile.name
|
|
||||||
}
|
val file = File(downloadItemPart.finalDestinationPath)
|
||||||
if (itemPart == null) {
|
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
|
||||||
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
if (file == null) {
|
||||||
|
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
|
||||||
|
} else {
|
||||||
|
if (downloadItemPart.audioTrack != null) {
|
||||||
|
val audioTrackFromServer = downloadItemPart.audioTrack
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||||
|
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||||
|
val localFile = LocalFile(
|
||||||
|
localFileId,
|
||||||
|
file.name,
|
||||||
|
downloadItemPart.finalDestinationUri.toString(),
|
||||||
|
file.getBasePath(ctx),
|
||||||
|
file.absolutePath,
|
||||||
|
file.getSimplePath(ctx),
|
||||||
|
file.mimeType,
|
||||||
|
file.length()
|
||||||
|
)
|
||||||
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
|
||||||
|
// Create new audio track
|
||||||
|
val track = AudioTrack(
|
||||||
|
audioTrackFromServer.index,
|
||||||
|
audioTrackFromServer.startOffset,
|
||||||
|
audioTrackFromServer.duration,
|
||||||
|
localFile.filename ?: "",
|
||||||
|
localFile.contentUrl,
|
||||||
|
localFile.mimeType ?: "",
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
localFileId,
|
||||||
|
null,
|
||||||
|
audioTrackFromServer.index
|
||||||
|
)
|
||||||
|
audioTracks.add(track)
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add podcast episodes to library
|
||||||
|
downloadItemPart.episode?.let { podcastEpisode ->
|
||||||
|
val podcast = localLibraryItem.media as Podcast
|
||||||
|
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||||
|
localEpisodeId = newEpisode.id
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (downloadItemPart.ebookFile != null) {
|
||||||
|
foundEBookFile = true
|
||||||
|
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||||
|
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||||
|
val localFile = LocalFile(
|
||||||
|
localFileId,
|
||||||
|
file.name,
|
||||||
|
Uri.fromFile(file).toString(),
|
||||||
|
file.getBasePath(ctx),
|
||||||
|
file.absolutePath,
|
||||||
|
file.getSimplePath(ctx),
|
||||||
|
file.mimeType,
|
||||||
|
file.length()
|
||||||
|
)
|
||||||
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
|
||||||
|
val ebookFile = EBookFile(
|
||||||
|
downloadItemPart.ebookFile.ino,
|
||||||
|
downloadItemPart.ebookFile.metadata,
|
||||||
|
downloadItemPart.ebookFile.ebookFormat,
|
||||||
|
true,
|
||||||
|
localFileId,
|
||||||
|
localFile.contentUrl
|
||||||
|
)
|
||||||
|
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||||
|
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||||
|
} else {
|
||||||
|
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||||
|
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
|
||||||
|
|
||||||
|
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||||
|
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||||
|
localLibraryItem.localFiles.add(localFile)
|
||||||
}
|
}
|
||||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
|
||||||
val audioTrackFromServer = itemPart.audioTrack
|
|
||||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Create new audio track
|
|
||||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
|
|
||||||
audioTracks.add(track)
|
|
||||||
|
|
||||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
|
||||||
|
|
||||||
// Add podcast episodes to library
|
|
||||||
itemPart.episode?.let { podcastEpisode ->
|
|
||||||
val podcast = localLibraryItem.media as Podcast
|
|
||||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
|
||||||
localEpisodeId = newEpisode.id
|
|
||||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
|
||||||
}
|
|
||||||
} 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())
|
|
||||||
|
|
||||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioTracks.isEmpty()) {
|
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||||
return cb(null)
|
return cb(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +386,175 @@ class FolderScanner(var ctx: Context) {
|
|||||||
progress = mediaProgress.progress,
|
progress = mediaProgress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
currentTime = mediaProgress.currentTime,
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
|
startedAt = mediaProgress.startedAt,
|
||||||
|
finishedAt = mediaProgress.finishedAt,
|
||||||
|
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||||
|
serverAddress = downloadItem.serverAddress,
|
||||||
|
serverUserId = downloadItem.serverUserId,
|
||||||
|
libraryItemId = downloadItem.libraryItemId,
|
||||||
|
episodeId = downloadItem.episodeId)
|
||||||
|
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||||
|
|
||||||
|
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||||
|
|
||||||
|
cb(downloadItemScanResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan item after download and create local library item
|
||||||
|
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||||
|
// If downloading to internal storage handle separately
|
||||||
|
if (downloadItem.isInternalStorage) {
|
||||||
|
scanInternalDownloadItem(downloadItem, cb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||||
|
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||||
|
|
||||||
|
var itemFolderId = ""
|
||||||
|
var itemFolderUrl = ""
|
||||||
|
var itemFolderBasePath = ""
|
||||||
|
var itemFolderAbsolutePath = ""
|
||||||
|
foldersFound.forEach {
|
||||||
|
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
||||||
|
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
||||||
|
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
||||||
|
itemFolderId = it.id
|
||||||
|
itemFolderUrl = it.uri.toString()
|
||||||
|
itemFolderBasePath = it.getBasePath(ctx)
|
||||||
|
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemFolderUrl == "") {
|
||||||
|
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||||
|
return cb(null)
|
||||||
|
}
|
||||||
|
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||||
|
|
||||||
|
if (df == null) {
|
||||||
|
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||||
|
return cb(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||||
|
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
||||||
|
|
||||||
|
// Search for files in media item folder
|
||||||
|
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||||
|
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||||
|
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||||
|
|
||||||
|
var localEpisodeId:String? = null
|
||||||
|
var localLibraryItem:LocalLibraryItem?
|
||||||
|
if (downloadItem.mediaType == "book") {
|
||||||
|
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||||
|
} else {
|
||||||
|
// Lookup or create podcast local library item
|
||||||
|
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
|
if (localLibraryItem == null) {
|
||||||
|
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||||
|
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||||
|
var foundEBookFile = false
|
||||||
|
|
||||||
|
filesFound.forEach { docFile ->
|
||||||
|
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||||
|
itemPart.filename == docFile.name
|
||||||
|
}
|
||||||
|
if (itemPart == null) {
|
||||||
|
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
||||||
|
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
||||||
|
}
|
||||||
|
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||||
|
val audioTrackFromServer = itemPart.audioTrack
|
||||||
|
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Create new audio track
|
||||||
|
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
|
||||||
|
audioTracks.add(track)
|
||||||
|
|
||||||
|
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||||
|
|
||||||
|
// Add podcast episodes to library
|
||||||
|
itemPart.episode?.let { podcastEpisode ->
|
||||||
|
val podcast = localLibraryItem.media as Podcast
|
||||||
|
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||||
|
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())
|
||||||
|
|
||||||
|
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||||
|
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||||
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For books sort audio tracks then set
|
||||||
|
if (downloadItem.mediaType == "book") {
|
||||||
|
audioTracks.sortBy { it.index }
|
||||||
|
|
||||||
|
var indexCheck = 1
|
||||||
|
var startOffset = 0.0
|
||||||
|
audioTracks.forEach { audioTrack ->
|
||||||
|
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||||
|
audioTrack.index = indexCheck
|
||||||
|
audioTrack.startOffset = startOffset
|
||||||
|
}
|
||||||
|
indexCheck++
|
||||||
|
startOffset += audioTrack.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||||
|
|
||||||
|
// If library item had media progress then make local media progress and save
|
||||||
|
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||||
|
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||||
|
val newLocalMediaProgress = LocalMediaProgress(
|
||||||
|
id = localMediaProgressId,
|
||||||
|
localLibraryItemId = localLibraryItemId,
|
||||||
|
localEpisodeId = localEpisodeId,
|
||||||
|
duration = mediaProgress.duration,
|
||||||
|
progress = mediaProgress.progress,
|
||||||
|
currentTime = mediaProgress.currentTime,
|
||||||
|
isFinished = false,
|
||||||
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
startedAt = mediaProgress.startedAt,
|
startedAt = mediaProgress.startedAt,
|
||||||
finishedAt = mediaProgress.finishedAt,
|
finishedAt = mediaProgress.finishedAt,
|
||||||
@@ -381,7 +586,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
var wasUpdated = false
|
var wasUpdated = false
|
||||||
|
|
||||||
// Search for files in media item folder
|
// Search for files in media item folder
|
||||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "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}")
|
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||||
|
|
||||||
filesFound.forEach {
|
filesFound.forEach {
|
||||||
@@ -427,7 +632,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||||
|
|
||||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||||
audioTrack.localFileId == localFile.id
|
audioTrack.localFileId == localFileId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingTrack == null) {
|
if (existingTrack == null) {
|
||||||
@@ -446,6 +651,16 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
wasUpdated = true
|
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
|
} else { // Check if cover is empty
|
||||||
if (localLibraryItem.coverContentUrl == null) {
|
if (localLibraryItem.coverContentUrl == null) {
|
||||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||||
@@ -44,6 +46,11 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
fun onDownloadItemComplete(jsobj:JSObject)
|
fun onDownloadItemComplete(jsobj:JSObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InternalProgressCallback {
|
||||||
|
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||||
|
fun onComplete(failed: Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var isDownloading:Boolean = false
|
var isDownloading:Boolean = false
|
||||||
}
|
}
|
||||||
@@ -65,11 +72,33 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
|
|
||||||
if (nextDownloadItemParts.size > 0) {
|
if (nextDownloadItemParts.size > 0) {
|
||||||
nextDownloadItemParts.forEach {
|
nextDownloadItemParts.forEach {
|
||||||
val dlRequest = it.getDownloadRequest()
|
if (it.isInternalStorage) {
|
||||||
val downloadId = downloadManager.enqueue(dlRequest)
|
val file = File(it.finalDestinationPath)
|
||||||
it.downloadId = downloadId
|
file.parentFile?.mkdirs()
|
||||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
|
||||||
currentDownloadItemParts.add(it)
|
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
|
||||||
|
val internalProgressCallback = (object : InternalProgressCallback {
|
||||||
|
override fun onProgress(totalBytesWritten:Long, progress: Long) {
|
||||||
|
it.bytesDownloaded = totalBytesWritten
|
||||||
|
it.progress = progress
|
||||||
|
}
|
||||||
|
override fun onComplete(failed:Boolean) {
|
||||||
|
it.failed = failed
|
||||||
|
it.completed = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
|
||||||
|
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
|
||||||
|
it.downloadId = 1
|
||||||
|
currentDownloadItemParts.add(it)
|
||||||
|
} else {
|
||||||
|
val dlRequest = it.getDownloadRequest()
|
||||||
|
val downloadId = downloadManager.enqueue(dlRequest)
|
||||||
|
it.downloadId = downloadId
|
||||||
|
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||||
|
currentDownloadItemParts.add(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,14 +118,25 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
isDownloading = true
|
isDownloading = true
|
||||||
|
|
||||||
while (currentDownloadItemParts.size > 0) {
|
while (currentDownloadItemParts.size > 0) {
|
||||||
|
|
||||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||||
for (downloadItemPart in itemParts) {
|
for (downloadItemPart in itemParts) {
|
||||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
if (downloadItemPart.isInternalStorage) {
|
||||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||||
|
|
||||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
if (downloadItemPart.completed) {
|
||||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||||
|
downloadItem?.let {
|
||||||
|
checkDownloadItemFinished(it)
|
||||||
|
}
|
||||||
|
currentDownloadItemParts.remove(downloadItemPart)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||||
|
|
||||||
|
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||||
|
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(500)
|
delay(500)
|
||||||
@@ -166,7 +206,6 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
currentDownloadItemParts.remove(downloadItemPart)
|
||||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||||
|
|
||||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
|
||||||
|
import okhttp3.*
|
||||||
|
import java.io.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
||||||
|
private val tag = "InternalDownloadManager"
|
||||||
|
|
||||||
|
private val client: OkHttpClient = OkHttpClient()
|
||||||
|
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun download(url:String) {
|
||||||
|
val request: Request = Request.Builder().url(url).build()
|
||||||
|
client.newCall(request).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "download URL $url FAILED")
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
val responseBody: ResponseBody = response.body
|
||||||
|
?: throw IllegalStateException("Response doesn't contain a file")
|
||||||
|
|
||||||
|
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
|
||||||
|
writer.write(responseBody.byteStream(), length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun close() {
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
|
||||||
|
AutoCloseable {
|
||||||
|
private val outputStream: OutputStream
|
||||||
|
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||||
|
|
||||||
|
init {
|
||||||
|
this.outputStream = outputStream
|
||||||
|
this.progressCallback = progressCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun write(inputStream: InputStream?, length: Long): Long {
|
||||||
|
BufferedInputStream(inputStream).use { input ->
|
||||||
|
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||||
|
var readBytes: Int
|
||||||
|
var totalBytes: Long = 0
|
||||||
|
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||||
|
totalBytes += readBytes.toLong()
|
||||||
|
outputStream.write(dataBuffer, 0, readBytes)
|
||||||
|
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||||
|
}
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
return totalBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun close() {
|
||||||
|
outputStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHUNK_SIZE = 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.metrics.PlaybackSession
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -21,6 +22,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
private var sleepTimerElapsed:Long = 0L
|
private var sleepTimerElapsed:Long = 0L
|
||||||
private var sleepTimerFinishedAt:Long = 0L
|
private var sleepTimerFinishedAt:Long = 0L
|
||||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||||
|
private var isFirstAutoSleepTimer: Boolean = true
|
||||||
|
private var sleepTimerSessionId:String = ""
|
||||||
|
|
||||||
private fun getCurrentTime():Long {
|
private fun getCurrentTime():Long {
|
||||||
return playerNotificationService.getCurrentTime()
|
return playerNotificationService.getCurrentTime()
|
||||||
@@ -61,6 +64,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
sleepTimerRunning = true
|
sleepTimerRunning = true
|
||||||
sleepTimerFinishedAt = 0L
|
sleepTimerFinishedAt = 0L
|
||||||
sleepTimerElapsed = 0L
|
sleepTimerElapsed = 0L
|
||||||
|
setVolume(1f)
|
||||||
|
|
||||||
// Register shake sensor
|
// Register shake sensor
|
||||||
playerNotificationService.registerSensor()
|
playerNotificationService.registerSensor()
|
||||||
@@ -80,10 +84,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
} else {
|
} else {
|
||||||
sleepTimerLength = time
|
sleepTimerLength = time
|
||||||
sleepTimerEndTime = 0L
|
sleepTimerEndTime = 0L
|
||||||
|
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
|
||||||
sleepTimerLength = getDuration() - getCurrentTime()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||||
@@ -107,17 +107,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||||
clearSleepTimer()
|
clearSleepTimer()
|
||||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut == true) {
|
// Start fading out audio down to 10% volume
|
||||||
// Set volume to 1 in case setting was enabled while fading
|
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||||
setVolume(1f)
|
val volume = 1f - (percentToReduce * 0.9f)
|
||||||
} else {
|
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||||
// Start fading out audio down to 10% volume
|
setVolume(volume)
|
||||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
} else {
|
||||||
val volume = 1f - (percentToReduce * 0.9f)
|
setVolume(1f)
|
||||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
|
||||||
setVolume(volume)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +122,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualSleepTimer(time: Long, isChapterTime:Boolean):Boolean {
|
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||||
|
sleepTimerSessionId = playbackSessionId
|
||||||
isAutoSleepTimer = false
|
isAutoSleepTimer = false
|
||||||
return setSleepTimer(time, isChapterTime)
|
return setSleepTimer(time, isChapterTime)
|
||||||
}
|
}
|
||||||
@@ -135,7 +133,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
sleepTimerTask = null
|
sleepTimerTask = null
|
||||||
sleepTimerEndTime = 0
|
sleepTimerEndTime = 0
|
||||||
sleepTimerRunning = false
|
sleepTimerRunning = false
|
||||||
isAutoSleepTimer = false
|
|
||||||
playerNotificationService.unregisterSensor()
|
playerNotificationService.unregisterSensor()
|
||||||
|
|
||||||
setVolume(1f)
|
setVolume(1f)
|
||||||
@@ -229,6 +226,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
return
|
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
|
// Set sleep timer
|
||||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||||
if (sleepTimerLength == 0L) {
|
if (sleepTimerLength == 0L) {
|
||||||
@@ -327,18 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||||
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||||
|
|
||||||
// In cases where end time is earlier then start time then we add a day to end time
|
val currentCalendar = Calendar.getInstance()
|
||||||
// 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.
|
// 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)) {
|
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)
|
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
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
|
// Set sleep timer
|
||||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||||
if (deviceSettings.sleepTimerLength == 0L) {
|
if (deviceSettings.sleepTimerLength == 0L) {
|
||||||
@@ -354,13 +375,23 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
isFirstAutoSleepTimer = true
|
||||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleMediaPlayEvent() {
|
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||||
checkShouldResetSleepTimer()
|
// 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()
|
checkAutoSleepTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
cb(serverItemsInProgress)
|
cb(serverItemsInProgress)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||||
serverItemsInProgress = itemsInProgress
|
serverItemsInProgress = itemsInProgress.filter {
|
||||||
|
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||||
|
libraryItem.checkHasTracks()
|
||||||
|
}
|
||||||
cb(serverItemsInProgress)
|
cb(serverItemsInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +397,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
|
|
||||||
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||||
if (libraryItemWrapper is LocalLibraryItem) {
|
if (libraryItemWrapper is LocalLibraryItem) {
|
||||||
cb(libraryItemWrapper.getPlaybackSession(episode))
|
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
|
||||||
} else {
|
} else {
|
||||||
val libraryItem = libraryItemWrapper as LibraryItem
|
val libraryItem = libraryItemWrapper as LibraryItem
|
||||||
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||||
|
|
||||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||||
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||||
|
|
||||||
fun start(playbackSession:PlaybackSession) {
|
fun start(playbackSession:PlaybackSession) {
|
||||||
@@ -215,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
|
|
||||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||||
|
|
||||||
currentPlaybackSession?.syncData(syncData)
|
currentPlaybackSession?.syncData(syncData)
|
||||||
|
|
||||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||||
@@ -243,11 +242,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
// Send sync to server also if connected to this server and local item belongs to this server
|
// Send sync to server also if connected to this server and local item belongs to this server
|
||||||
if (hasNetworkConnection && 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 ->
|
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
playerNotificationService.alertSyncSuccess()
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ data class DownloadItem(
|
|||||||
val media: MediaType,
|
val media: MediaType,
|
||||||
val downloadItemParts: MutableList<DownloadItemPart>
|
val downloadItemParts: MutableList<DownloadItemPart>
|
||||||
) {
|
) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.AudioTrack
|
import com.audiobookshelf.app.data.AudioTrack
|
||||||
|
import com.audiobookshelf.app.data.EBookFile
|
||||||
import com.audiobookshelf.app.data.LocalFolder
|
import com.audiobookshelf.app.data.LocalFolder
|
||||||
import com.audiobookshelf.app.data.PodcastEpisode
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -20,6 +21,7 @@ data class DownloadItemPart(
|
|||||||
val localFolderName: String,
|
val localFolderName: String,
|
||||||
val localFolderUrl: String,
|
val localFolderUrl: String,
|
||||||
val localFolderId: String,
|
val localFolderId: String,
|
||||||
|
val ebookFile: EBookFile?,
|
||||||
val audioTrack: AudioTrack?,
|
val audioTrack: AudioTrack?,
|
||||||
val episode: PodcastEpisode?,
|
val episode: PodcastEpisode?,
|
||||||
var completed:Boolean,
|
var completed:Boolean,
|
||||||
@@ -35,12 +37,12 @@ data class DownloadItemPart(
|
|||||||
var bytesDownloaded: Long
|
var bytesDownloaded: Long
|
||||||
) {
|
) {
|
||||||
companion object {
|
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 destinationUri = Uri.fromFile(destinationFile)
|
||||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||||
|
|
||||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
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)
|
val downloadUri = Uri.parse(downloadUrl)
|
||||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||||
return DownloadItemPart(
|
return DownloadItemPart(
|
||||||
@@ -53,6 +55,7 @@ data class DownloadItemPart(
|
|||||||
localFolderName = localFolder.name,
|
localFolderName = localFolder.name,
|
||||||
localFolderUrl = localFolder.contentUrl,
|
localFolderUrl = localFolder.contentUrl,
|
||||||
localFolderId = localFolder.id,
|
localFolderId = localFolder.id,
|
||||||
|
ebookFile = ebookFile,
|
||||||
audioTrack = audioTrack,
|
audioTrack = audioTrack,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
completed = false,
|
completed = false,
|
||||||
@@ -70,6 +73,12 @@ data class DownloadItemPart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getDownloadRequest(): DownloadManager.Request {
|
fun getDownloadRequest(): DownloadManager.Request {
|
||||||
val dlRequest = DownloadManager.Request(uri)
|
val dlRequest = DownloadManager.Request(uri)
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startRouteScan(connListener:ChromecastListener) {
|
fun startRouteScan(connListener:ChromecastListener) {
|
||||||
var callback = object : ScanCallback() {
|
val callback = object : ScanCallback() {
|
||||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||||
// if the routes have changed, we may have an available device
|
// if the routes have changed, we may have an available device
|
||||||
@@ -136,7 +136,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
|||||||
// Let the client know a receiver is available
|
// Let the client know a receiver is available
|
||||||
connListener.onReceiverAvailableUpdate(true)
|
connListener.onReceiverAvailableUpdate(true)
|
||||||
// Since we have a receiver we may also have an active session
|
// Since we have a receiver we may also have an active session
|
||||||
var session = getSessionManager()?.currentCastSession
|
val session = getSessionManager()?.currentCastSession
|
||||||
// If we do have a session
|
// If we do have a session
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
// Let the client know
|
// Let the client know
|
||||||
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
|
|||||||
onRouteUpdate(outRoutes)
|
onRouteUpdate(outRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||||
onFilteredRouteUpdate()
|
onFilteredRouteUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||||
onFilteredRouteUpdate()
|
onFilteredRouteUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||||
onFilteredRouteUpdate()
|
onFilteredRouteUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
|
|||||||
import com.google.android.exoplayer2.C.TrackType
|
import com.google.android.exoplayer2.C.TrackType
|
||||||
import com.google.android.exoplayer2.MediaMetadata
|
import com.google.android.exoplayer2.MediaMetadata
|
||||||
import com.google.android.exoplayer2.Player.*
|
import com.google.android.exoplayer2.Player.*
|
||||||
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo
|
import com.google.android.exoplayer2.Tracks.Group
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||||
import com.google.android.exoplayer2.source.TrackGroup
|
import com.google.android.exoplayer2.source.TrackGroup
|
||||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||||
import com.google.android.exoplayer2.text.Cue
|
import com.google.android.exoplayer2.text.CueGroup
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelection
|
import com.google.android.exoplayer2.trackselection.TrackSelection
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
||||||
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
private var myPlayWhenReady:Boolean = false
|
private var myPlayWhenReady:Boolean = false
|
||||||
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
||||||
private var myAvailableCommands: Commands
|
private var myAvailableCommands: Commands
|
||||||
private var myCurrentTracksInfo: TracksInfo
|
private var myCurrentTracksInfo: Tracks
|
||||||
private var myCurrentTrackGroups: TrackGroupArray
|
private var myCurrentTrackGroups: TrackGroupArray
|
||||||
private var myCurrentTrackSelections: TrackSelectionArray
|
private var myCurrentTrackSelections: TrackSelectionArray
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||||
COMMAND_GET_TRACK_INFOS)
|
COMMAND_GET_TRACKS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
var currentPlaybackState = Player.STATE_IDLE
|
var currentPlaybackState = Player.STATE_IDLE
|
||||||
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
Log.d(tag, "Init CastPlayer")
|
Log.d(tag, "Init CastPlayer")
|
||||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
myCurrentTracksInfo = Tracks.EMPTY
|
||||||
statusListener = StatusListener()
|
statusListener = StatusListener()
|
||||||
timelineTracker = CastTimelineTracker()
|
timelineTracker = CastTimelineTracker()
|
||||||
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
||||||
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
||||||
listeners.queueEvent(
|
listeners.queueEvent(
|
||||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
|
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
|
||||||
}
|
}
|
||||||
updateAvailableCommandsAndNotifyIfChanged()
|
updateAvailableCommandsAndNotifyIfChanged()
|
||||||
listeners.flushEvents()
|
listeners.flushEvents()
|
||||||
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
val hasChanged = !myCurrentTrackGroups.isEmpty
|
val hasChanged = !myCurrentTrackGroups.isEmpty
|
||||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
myCurrentTracksInfo = Tracks.EMPTY
|
||||||
return hasChanged
|
return hasChanged
|
||||||
}
|
}
|
||||||
var activeTrackIds = mediaStatus.activeTrackIds
|
var activeTrackIds = mediaStatus.activeTrackIds
|
||||||
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
||||||
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
||||||
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
|
val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
|
||||||
for (i in castMediaTracks.indices) {
|
for (i in castMediaTracks.indices) {
|
||||||
val mediaTrack = castMediaTracks[i]
|
val mediaTrack = castMediaTracks[i]
|
||||||
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
||||||
@@ -428,18 +428,16 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
|
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
|
||||||
val trackSelected = booleanArrayOf(selected)
|
val trackSelected = booleanArrayOf(selected)
|
||||||
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
|
trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
val tg = trackGroups.filterNotNull().toTypedArray()
|
val tg = trackGroups.filterNotNull().toTypedArray()
|
||||||
var ts = trackSelections.filterNotNull().toTypedArray()
|
val ts = trackSelections.filterNotNull().toTypedArray()
|
||||||
var tgi = trackGroupInfos.filterNotNull().toMutableList()
|
val tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||||
val newTrackGroups = TrackGroupArray(*tg)
|
val newTrackGroups = TrackGroupArray(*tg)
|
||||||
val newTrackSelections = TrackSelectionArray(*ts)
|
val newTrackSelections = TrackSelectionArray(*ts)
|
||||||
val newTracksInfo = TracksInfo(tgi)
|
val newTracksInfo = Tracks(tgi)
|
||||||
if (newTrackGroups != currentTrackGroups
|
if (newTracksInfo != currentTracks) {
|
||||||
|| newTrackSelections != currentTrackSelections
|
|
||||||
|| newTracksInfo != currentTracksInfo) {
|
|
||||||
myCurrentTrackSelections = newTrackSelections
|
myCurrentTrackSelections = newTrackSelections
|
||||||
myCurrentTrackGroups = newTrackGroups
|
myCurrentTrackGroups = newTrackGroups
|
||||||
myCurrentTracksInfo = newTracksInfo
|
myCurrentTracksInfo = newTracksInfo
|
||||||
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
listeners.flushEvents()
|
listeners.flushEvents()
|
||||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||||
updatePlayerStateAndNotifyIfChanged()
|
updatePlayerStateAndNotifyIfChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
|
||||||
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||||
val statusCode: Int = it.status.statusCode
|
val statusCode: Int = it.status.statusCode
|
||||||
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
||||||
Log.e(tag, "Seek failed. Error code $statusCode")
|
Log.e(tag, "Seek failed. Error code $statusCode")
|
||||||
@@ -645,14 +643,14 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
|
|
||||||
// We assume the default position is 0. There is no support for seeking to the default position
|
// We assume the default position is 0. There is no support for seeking to the default position
|
||||||
// in RemoteMediaClient.
|
// in RemoteMediaClient.
|
||||||
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
||||||
if (mediaStatus != null) {
|
if (mediaStatus != null) {
|
||||||
if (currentMediaItemIndex != mediaItemIndex) {
|
if (currentMediaItemIndex != mediaItemIndex) {
|
||||||
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
|
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
|
||||||
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
|
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
|
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
|
||||||
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
||||||
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
||||||
}
|
}
|
||||||
val oldPosition = getCurrentPositionInfo()
|
val oldPosition = getCurrentPositionInfo()
|
||||||
@@ -709,7 +707,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
|
|
||||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||||
updatePlaybackRateAndNotifyIfChanged()
|
updatePlaybackRateAndNotifyIfChanged()
|
||||||
listeners.flushEvents()
|
listeners.flushEvents()
|
||||||
}
|
}
|
||||||
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
sessionManager.endCurrentSession(false)
|
sessionManager.endCurrentSession(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentTrackGroups(): TrackGroupArray {
|
override fun getCurrentTracks(): Tracks {
|
||||||
return this.myCurrentTrackGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTrackSelections(): TrackSelectionArray {
|
|
||||||
return this.myCurrentTrackSelections
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTracksInfo(): TracksInfo {
|
|
||||||
return this.myCurrentTracksInfo
|
return this.myCurrentTracksInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
return VideoSize.UNKNOWN
|
return VideoSize.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentCues(): MutableList<Cue> {
|
override fun getSurfaceSize(): Size {
|
||||||
return mutableListOf()
|
return Size.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentCues(): CueGroup {
|
||||||
|
return CueGroup.EMPTY_TIME_ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getDeviceInfo(): DeviceInfo {
|
override fun getDeviceInfo(): DeviceInfo {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
|||||||
import com.google.android.exoplayer2.PlaybackException
|
import com.google.android.exoplayer2.PlaybackException
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
|
|
||||||
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||||
|
|
||||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||||
var tag = "PlayerListener"
|
var tag = "PlayerListener"
|
||||||
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
|
|
||||||
lazyIsPlaying = isPlaying
|
lazyIsPlaying = isPlaying
|
||||||
|
|
||||||
|
// Update widget
|
||||||
|
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
Log.d(tag, "SeekBackTime: Player is playing")
|
Log.d(tag, "SeekBackTime: Player is playing")
|
||||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||||
@@ -74,12 +77,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
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
|
// Check if playback session still exists or sync media progress if updated
|
||||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
// val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
// if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
// val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||||
if (!shouldCarryOn) return
|
// if (!shouldCarryOn) return
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (seekBackTime > 0L) {
|
if (seekBackTime > 0L) {
|
||||||
playerNotificationService.seekBackward(seekBackTime)
|
playerNotificationService.seekBackward(seekBackTime)
|
||||||
@@ -92,12 +96,15 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
|
|
||||||
// Start/stop progress sync interval
|
// Start/stop progress sync interval
|
||||||
if (isPlaying) {
|
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
|
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 {
|
} else {
|
||||||
playerNotificationService.mediaProgressSyncer.pause {
|
playerNotificationService.mediaProgressSyncer.pause {
|
||||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||||
@@ -105,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
||||||
|
|
||||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvents(player: Player, events: Player.Events) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
|
|||||||
+1
-5
@@ -44,11 +44,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
|||||||
} else {
|
} else {
|
||||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||||
|
|
||||||
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
|
if (PlayerNotificationService.isSwitchingPlayer) {
|
||||||
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
|
|
||||||
Log.d(tag, "PNS is not closed - closing it now")
|
|
||||||
playerNotificationService.closePlayback()
|
|
||||||
} else if (PlayerNotificationService.isSwitchingPlayer) {
|
|
||||||
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
||||||
// so we don't want to cancel the playback during this switch
|
// so we don't want to cancel the playback during this switch
|
||||||
Log.d(tag, "PNS is switching player")
|
Log.d(tag, "PNS is switching player")
|
||||||
|
|||||||
+104
-64
@@ -1,8 +1,7 @@
|
|||||||
package com.audiobookshelf.app.player
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -10,12 +9,10 @@ import android.graphics.Color
|
|||||||
import android.graphics.ImageDecoder
|
import android.graphics.ImageDecoder
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.*
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.provider.Settings
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
@@ -23,15 +20,11 @@ import android.support.v4.media.session.MediaControllerCompat
|
|||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
|
||||||
import android.widget.RemoteViews
|
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
|
||||||
import com.audiobookshelf.app.MediaPlayerWidget
|
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.data.DeviceInfo
|
import com.audiobookshelf.app.data.DeviceInfo
|
||||||
@@ -46,6 +39,8 @@ import com.google.android.exoplayer2.audio.AudioAttributes
|
|||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
||||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||||
|
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
||||||
|
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import com.google.android.exoplayer2.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||||
@@ -69,7 +64,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tag = "PlayerNotificationService"
|
private val tag = "PlayerNotificationServ"
|
||||||
|
|
||||||
interface ClientEventEmitter {
|
interface ClientEventEmitter {
|
||||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
fun onPlaybackSession(playbackSession:PlaybackSession)
|
||||||
@@ -202,6 +197,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
ctx = this
|
ctx = this
|
||||||
|
|
||||||
|
// Initialize Paper
|
||||||
|
DbManager.initialize(ctx)
|
||||||
|
|
||||||
|
// Initialize widget
|
||||||
|
DeviceManager.initializeWidgetUpdater(ctx)
|
||||||
|
|
||||||
// To listen for network change from metered to unmetered
|
// To listen for network change from metered to unmetered
|
||||||
val networkRequest = NetworkRequest.Builder()
|
val networkRequest = NetworkRequest.Builder()
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
@@ -263,7 +264,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
playerNotificationManager.setUseNextAction(false)
|
playerNotificationManager.setUseNextAction(false)
|
||||||
playerNotificationManager.setUsePreviousAction(false)
|
playerNotificationManager.setUsePreviousAction(false)
|
||||||
playerNotificationManager.setUseChronometer(false)
|
playerNotificationManager.setUseChronometer(false)
|
||||||
playerNotificationManager.setUseStopAction(true)
|
playerNotificationManager.setUseStopAction(false)
|
||||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||||
@@ -287,10 +288,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return MediaDescriptionCompat.Builder().build()
|
return MediaDescriptionCompat.Builder().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
||||||
|
|
||||||
var bitmap:Bitmap? = null
|
var bitmap: Bitmap? = null
|
||||||
// Local covers get bitmap
|
// Local covers get bitmap
|
||||||
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
||||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
@@ -361,7 +362,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.build()
|
.build()
|
||||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||||
mPlayer.addListener(PlayerListener(this))
|
mPlayer.addListener(PlayerListener(this))
|
||||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build()
|
||||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
mPlayer.setAudioAttributes(audioAttributes, true)
|
||||||
|
|
||||||
//attach player to playerNotificationManager
|
//attach player to playerNotificationManager
|
||||||
@@ -393,7 +394,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||||
mediaSession.setMetadata(metadata)
|
mediaSession.setMetadata(metadata)
|
||||||
val mediaItems = playbackSession.getMediaItems()
|
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||||
initialPlaybackRate = playbackRate
|
initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
@@ -437,12 +438,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
if (playbackSession.isLocal) {
|
if (playbackSession.isLocal) {
|
||||||
Log.d(tag, "Playing Local Item")
|
Log.d(tag, "Playing Local Item")
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
|
||||||
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
|
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
|
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
|
||||||
} else if (!playbackSession.isHLS) {
|
} else if (!playbackSession.isHLS) {
|
||||||
Log.d(tag, "Direct Playing Item")
|
Log.d(tag, "Direct Playing Item")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
|
||||||
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
|
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
|
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
|
||||||
|
}
|
||||||
|
|
||||||
dataSourceFactory.setUserAgent(channelId)
|
dataSourceFactory.setUserAgent(channelId)
|
||||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Playing HLS Item")
|
Log.d(tag, "Playing HLS Item")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
@@ -482,7 +497,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
||||||
val mediaItems = playbackSession.getMediaItems()
|
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||||
val customActionProviders = mutableListOf(
|
val customActionProviders = mutableListOf(
|
||||||
JumpBackwardCustomActionProvider(),
|
JumpBackwardCustomActionProvider(),
|
||||||
JumpForwardCustomActionProvider(),
|
JumpForwardCustomActionProvider(),
|
||||||
@@ -862,10 +877,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
fun closePlayback(calledOnError:Boolean? = false) {
|
fun closePlayback(calledOnError:Boolean? = false) {
|
||||||
Log.d(tag, "closePlayback")
|
Log.d(tag, "closePlayback")
|
||||||
|
val isLocal = mediaProgressSyncer.currentIsLocal
|
||||||
|
val currentSessionId = mediaProgressSyncer.currentSessionId
|
||||||
if (mediaProgressSyncer.listeningTimerRunning) {
|
if (mediaProgressSyncer.listeningTimerRunning) {
|
||||||
Log.i(tag, "About to close playback so stopping media progress syncer first")
|
Log.i(tag, "About to close playback so stopping media progress syncer first")
|
||||||
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
|
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
|
||||||
Log.d(tag, "Media Progress syncer stopped")
|
Log.d(tag, "Media Progress syncer stopped")
|
||||||
|
|
||||||
|
// If not local session then close on server
|
||||||
|
if (!isLocal && currentSessionId != "") {
|
||||||
|
apiHandler.closePlaybackSession(currentSessionId) {
|
||||||
|
Log.d(tag, "Closed playback session $currentSessionId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If not local session then close on server
|
||||||
|
if (!isLocal && currentSessionId != "") {
|
||||||
|
apiHandler.closePlaybackSession(currentSessionId) {
|
||||||
|
Log.d(tag, "Closed playback session $currentSessionId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,8 +910,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
mediaProgressSyncer.reset()
|
mediaProgressSyncer.reset()
|
||||||
clientEventEmitter?.onPlaybackClosed()
|
clientEventEmitter?.onPlaybackClosed()
|
||||||
|
|
||||||
PlayerListener.lastPauseTime = 0
|
PlayerListener.lastPauseTime = 0
|
||||||
isClosed = true
|
isClosed = true
|
||||||
|
DeviceManager.widgetUpdater?.onPlayerClosed()
|
||||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
@@ -894,7 +927,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDeviceInfo(): DeviceInfo {
|
@SuppressLint("HardwareIds")
|
||||||
|
fun getDeviceInfo(): DeviceInfo {
|
||||||
/* EXAMPLE
|
/* EXAMPLE
|
||||||
manufacturer: Google
|
manufacturer: Google
|
||||||
model: Pixel 6
|
model: Pixel 6
|
||||||
@@ -902,7 +936,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
sdkVersion: 32
|
sdkVersion: 32
|
||||||
appVersion: 0.9.46-beta
|
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.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
@@ -959,13 +994,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val extras = Bundle()
|
val extras = Bundle()
|
||||||
extras.putBoolean(
|
extras.putBoolean(
|
||||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
|
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||||
|
)
|
||||||
extras.putInt(
|
extras.putInt(
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
|
||||||
|
)
|
||||||
extras.putInt(
|
extras.putInt(
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
|
||||||
|
)
|
||||||
|
|
||||||
BrowserRoot(AUTO_MEDIA_ROOT, extras)
|
BrowserRoot(AUTO_MEDIA_ROOT, extras)
|
||||||
}
|
}
|
||||||
@@ -976,39 +1014,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
result.detach()
|
result.detach()
|
||||||
|
|
||||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||||
Log.d(tag, "Loading podcast episodes")
|
|
||||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
|
||||||
result.sendResult(it)
|
|
||||||
}
|
|
||||||
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
|
||||||
|
|
||||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
|
||||||
val children = libraryItems.map { libraryItem ->
|
|
||||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
|
||||||
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
|
||||||
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
|
||||||
} else {
|
|
||||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
|
||||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
|
||||||
libraryItem.localLibraryItemId = localLibraryItem?.id
|
|
||||||
val description = libraryItem.getMediaDescription(progress, ctx)
|
|
||||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
|
||||||
}
|
|
||||||
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
|
||||||
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
|
|
||||||
localBooks.forEach { localLibraryItem ->
|
localBooks.forEach { localLibraryItem ->
|
||||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
|
||||||
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||||
|
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
||||||
|
|
||||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localPodcasts.forEach { localLibraryItem ->
|
localPodcasts.forEach { localLibraryItem ->
|
||||||
@@ -1024,19 +1042,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
val progress: MediaProgressWrapper?
|
val progress: MediaProgressWrapper?
|
||||||
val mediaDescription:MediaDescriptionCompat
|
val mediaDescription:MediaDescriptionCompat
|
||||||
if (itemInProgress.episode != null) {
|
if (itemInProgress.episode != null) {
|
||||||
if (itemInProgress.isLocal) {
|
if (itemInProgress.isLocal) {
|
||||||
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||||
} else {
|
} else {
|
||||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||||
|
|
||||||
// to show download icon
|
// to show download icon
|
||||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||||
localLibraryItem?.let { lli ->
|
localLibraryItem?.let { lli ->
|
||||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
||||||
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
||||||
} else {
|
} else {
|
||||||
if (itemInProgress.isLocal) {
|
if (itemInProgress.isLocal) {
|
||||||
@@ -1052,16 +1070,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
result.sendResult(localBrowseItems)
|
result.sendResult(localBrowseItems)
|
||||||
} else {
|
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) {
|
||||||
mediaManager.loadAndroidAutoItems {
|
mediaManager.loadAndroidAutoItems {
|
||||||
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
|
|
||||||
val children = browseTree[parentMediaId]?.map { item ->
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
||||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
|
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||||
|
Log.d(tag, "Loading items for library $parentMediaId")
|
||||||
|
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||||
|
val children = libraryItems.map { libraryItem ->
|
||||||
|
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||||
|
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
||||||
|
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
|
} else {
|
||||||
|
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||||
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
||||||
|
libraryItem.localLibraryItemId = localLibraryItem?.id
|
||||||
|
val description = libraryItem.getMediaDescription(progress, ctx)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Loading podcast episodes for podcast $parentMediaId")
|
||||||
|
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
||||||
|
result.sendResult(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
|
|
||||||
initCastManager()
|
try {
|
||||||
|
initCastManager()
|
||||||
|
} catch(e:Exception) {
|
||||||
|
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
|
||||||
|
}
|
||||||
|
|
||||||
val foregroundServiceReady : () -> Unit = {
|
val foregroundServiceReady : () -> Unit = {
|
||||||
playerNotificationService = mainActivity.foregroundService
|
playerNotificationService = mainActivity.foregroundService
|
||||||
@@ -176,7 +180,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||||
val startTimeOverride = call.getDouble("startTime")
|
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()) {
|
if (libraryItemId.isEmpty()) {
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
Log.e(tag, "Invalid call to play library item no library item id")
|
||||||
@@ -197,7 +201,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||||
val playbackSession = it.getPlaybackSession(episode)
|
val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo())
|
||||||
if (startTimeOverride != null) {
|
if (startTimeOverride != null) {
|
||||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||||
playbackSession.currentTime = startTimeOverride
|
playbackSession.currentTime = startTimeOverride
|
||||||
@@ -337,7 +341,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(time, isChapterTime)
|
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||||
|
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime)
|
||||||
val ret = JSObject()
|
val ret = JSObject()
|
||||||
ret.put("success", success)
|
ret.put("success", success)
|
||||||
call.resolve(ret)
|
call.resolve(ret)
|
||||||
|
|||||||
@@ -231,9 +231,15 @@ class AbsDatabase : Plugin() {
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||||
} else {
|
} else {
|
||||||
|
// Remove all local sessions
|
||||||
|
savedSessions.forEach {
|
||||||
|
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||||
|
}
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,6 +271,8 @@ class AbsDatabase : Plugin() {
|
|||||||
progress = mediaProgress.progress,
|
progress = mediaProgress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
currentTime = mediaProgress.currentTime,
|
||||||
isFinished = mediaProgress.isFinished,
|
isFinished = mediaProgress.isFinished,
|
||||||
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
startedAt = mediaProgress.startedAt,
|
startedAt = mediaProgress.startedAt,
|
||||||
finishedAt = mediaProgress.finishedAt,
|
finishedAt = mediaProgress.finishedAt,
|
||||||
@@ -345,6 +353,8 @@ class AbsDatabase : Plugin() {
|
|||||||
progress = if (isFinished) 1.0 else 0.0,
|
progress = if (isFinished) 1.0 else 0.0,
|
||||||
currentTime = 0.0,
|
currentTime = 0.0,
|
||||||
isFinished = isFinished,
|
isFinished = isFinished,
|
||||||
|
ebookLocation = null,
|
||||||
|
ebookProgress = null,
|
||||||
lastUpdate = currentTime,
|
lastUpdate = currentTime,
|
||||||
startedAt = if (isFinished) currentTime else 0L,
|
startedAt = if (isFinished) currentTime else 0L,
|
||||||
finishedAt = if (isFinished) currentTime else null,
|
finishedAt = if (isFinished) currentTime else null,
|
||||||
@@ -389,6 +399,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
|
@PluginMethod
|
||||||
fun updateLocalTrackOrder(call:PluginCall) {
|
fun updateLocalTrackOrder(call:PluginCall) {
|
||||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
|
|||||||
|
|
||||||
import android.app.DownloadManager
|
import android.app.DownloadManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
@@ -58,7 +57,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
val libraryItemId = call.data.getString("libraryItemId").toString()
|
||||||
var episodeId = call.data.getString("episodeId").toString()
|
var episodeId = call.data.getString("episodeId").toString()
|
||||||
if (episodeId == "null") episodeId = ""
|
if (episodeId == "null") episodeId = ""
|
||||||
val localFolderId = call.data.getString("localFolderId").toString()
|
var localFolderId = call.data.getString("localFolderId", "").toString()
|
||||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||||
|
|
||||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||||
@@ -73,9 +72,18 @@ class AbsDownloader : Plugin() {
|
|||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||||
|
|
||||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
if (localFolderId == "") {
|
||||||
if (localFolder != null) {
|
localFolderId = "internal-${libraryItem.mediaType}"
|
||||||
|
}
|
||||||
|
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||||
|
|
||||||
|
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||||
|
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||||
|
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
||||||
|
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localFolder != null) {
|
||||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||||
@@ -101,12 +109,6 @@ class AbsDownloader : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean folder path so it can be used in URL
|
|
||||||
private fun cleanRelPath(relPath: String): String {
|
|
||||||
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
|
|
||||||
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
||||||
private fun getFilenameFromRelPath(relPath: String): String {
|
private fun getFilenameFromRelPath(relPath: String): String {
|
||||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||||
@@ -128,7 +130,9 @@ class AbsDownloader : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
val isInternal = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
|
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
|
||||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||||
|
|
||||||
@@ -139,13 +143,40 @@ class AbsDownloader : Plugin() {
|
|||||||
val tracks = libraryItem.media.getAudioTracks()
|
val tracks = libraryItem.media.getAudioTracks()
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${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 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 = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||||
|
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
|
// Create download item part for each audio track
|
||||||
|
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
|
||||||
tracks.forEach { audioTrack ->
|
tracks.forEach { audioTrack ->
|
||||||
val fileSize = audioTrack.metadata?.size ?: 0
|
val fileSize = audioTrack.metadata?.size ?: 0
|
||||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
|
||||||
|
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
|
||||||
|
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
|
||||||
|
|
||||||
|
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||||
|
|
||||||
@@ -162,7 +193,7 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +218,7 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +229,15 @@ class AbsDownloader : Plugin() {
|
|||||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||||
|
|
||||||
val audioTrack = episode?.audioTrack
|
val audioTrack = episode?.audioTrack
|
||||||
|
val audioFileIno = episode?.audioFile?.ino
|
||||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||||
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
Log.d(tag, "Starting podcast episode download")
|
||||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||||
|
|
||||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
|
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||||
|
|
||||||
@@ -216,7 +248,7 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||||
@@ -232,7 +264,7 @@ class AbsDownloader : Plugin() {
|
|||||||
if (finalDestinationFile.exists()) {
|
if (finalDestinationFile.exists()) {
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||||
} else {
|
} 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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -23,6 +22,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsFileSystem")
|
@CapacitorPlugin(name = "AbsFileSystem")
|
||||||
class AbsFileSystem : Plugin() {
|
class AbsFileSystem : Plugin() {
|
||||||
@@ -164,6 +164,13 @@ class AbsFileSystem : Plugin() {
|
|||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getSDKVersion(call: PluginCall) {
|
||||||
|
val jsObject = JSObject()
|
||||||
|
jsObject.put("version", Build.VERSION.SDK_INT)
|
||||||
|
call.resolve(jsObject)
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun scanFolder(call: PluginCall) {
|
fun scanFolder(call: PluginCall) {
|
||||||
val folderId = call.data.getString("folderId", "").toString()
|
val folderId = call.data.getString("folderId", "").toString()
|
||||||
@@ -226,8 +233,46 @@ class AbsFileSystem : Plugin() {
|
|||||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||||
|
|
||||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
// Check if should delete subfolder
|
||||||
val success = docfile?.delete() == true
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
|
|
||||||
|
val success: Boolean
|
||||||
|
|
||||||
|
// If internal library item use File to delete
|
||||||
|
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||||
|
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||||
|
val file = File(absolutePath)
|
||||||
|
success = file.deleteRecursively()
|
||||||
|
} else {
|
||||||
|
var subfolderPathToDelete = ""
|
||||||
|
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))
|
||||||
|
success = docfile?.delete() == true
|
||||||
|
|
||||||
|
if (subfolderPathToDelete != "") {
|
||||||
|
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||||
|
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||||
|
docfilesub?.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.getcapacitor.JSObject
|
|||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.internal.EMPTY_REQUEST
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -45,11 +46,11 @@ class ApiHandler(var ctx:Context) {
|
|||||||
makeRequest(request, httpClient, cb)
|
makeRequest(request, httpClient, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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 address = config?.address ?: DeviceManager.serverAddress
|
||||||
val token = config?.token ?: DeviceManager.token
|
val token = config?.token ?: DeviceManager.token
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
|
||||||
val requestUrl = "${address}$endpoint"
|
val requestUrl = "${address}$endpoint"
|
||||||
Log.d(tag, "postRequest to $requestUrl")
|
Log.d(tag, "postRequest to $requestUrl")
|
||||||
val request = Request.Builder().post(requestBody)
|
val request = Request.Builder().post(requestBody)
|
||||||
@@ -292,6 +293,13 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
|
||||||
|
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
|
||||||
|
postRequest("/api/session/$playbackSessionId/close", null, null) {
|
||||||
|
cb(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||||
Log.d(tag, "authorize: Authorizing ${config.address}")
|
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||||
postRequest("/api/authorize", JSObject(), config) {
|
postRequest("/api/authorize", JSObject(), config) {
|
||||||
@@ -315,18 +323,19 @@ class ApiHandler(var ctx:Context) {
|
|||||||
|
|
||||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||||
|
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||||
postRequest("/api/session/local-all", payload, null) {
|
postRequest("/api/session/local-all", payload, null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
|
Log.e(tag, "Failed to sync local sessions")
|
||||||
cb(false, it.getString("error"))
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} else {
|
||||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||||
response.results.forEach { localSessionSyncResult ->
|
response.results.forEach { localSessionSyncResult ->
|
||||||
|
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
||||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||||
if (localSessionSyncResult.progressSynced == true) {
|
if (localSessionSyncResult.progressSynced == true) {
|
||||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||||
MediaEventManager.saveEvent(session, syncResult)
|
MediaEventManager.saveEvent(session, syncResult)
|
||||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
|
||||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||||
} else if (!localSessionSyncResult.success) {
|
} else if (!localSessionSyncResult.success) {
|
||||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||||
@@ -356,6 +365,17 @@ class ApiHandler(var ctx:Context) {
|
|||||||
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}")
|
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)
|
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
|
} 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths>
|
||||||
<external-path name="my_images" path="." />
|
<files-path name="downloads" path="downloads/" />
|
||||||
<cache-path name="my_cache_images" path="." />
|
</paths>
|
||||||
</paths>
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.google.gms:google-services:4.3.13'
|
classpath 'com.google.gms:google-services:4.4.0'
|
||||||
classpath 'com.android.tools.build:gradle:7.4.0'
|
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
|||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-clipboard'
|
||||||
|
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
||||||
|
|
||||||
include ':capacitor-dialog'
|
include ':capacitor-dialog'
|
||||||
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
|
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 \
|
|||||||
# Android operating system, and which are packaged with your app's APK
|
# Android operating system, and which are packaged with your app's APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
# Automatically convert third-party libraries to use AndroidX
|
|
||||||
android.enableJetifier=true
|
|
||||||
|
|
||||||
kapt.use.worker.api=false
|
kapt.use.worker.api=false
|
||||||
|
|
||||||
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
|
|||||||
BIN
Binary file not shown.
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
#Sun Apr 17 13:28:55 CDT 2022
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Vendored
+172
-113
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright 2015 the original author or authors.
|
# Copyright © 2015-2021 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,67 +17,101 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
##
|
#
|
||||||
## Gradle start up script for UN*X
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
##
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
# Attempt to set APP_HOME
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
PRG="$0"
|
|
||||||
# Need this for relative symlinks.
|
|
||||||
while [ -h "$PRG" ] ; do
|
|
||||||
ls=`ls -ld "$PRG"`
|
|
||||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
|
||||||
if expr "$link" : '/.*' > /dev/null; then
|
|
||||||
PRG="$link"
|
|
||||||
else
|
|
||||||
PRG=`dirname "$PRG"`"/$link"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
SAVED="`pwd`"
|
|
||||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
|
||||||
APP_HOME="`pwd -P`"
|
|
||||||
cd "$SAVED" >/dev/null
|
|
||||||
|
|
||||||
APP_NAME="Gradle"
|
# Resolve links: $0 may be a link
|
||||||
APP_BASE_NAME=`basename "$0"`
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD="maximum"
|
MAX_FD=maximum
|
||||||
|
|
||||||
warn () {
|
warn () {
|
||||||
echo "$*"
|
echo "$*"
|
||||||
}
|
} >&2
|
||||||
|
|
||||||
die () {
|
die () {
|
||||||
echo
|
echo
|
||||||
echo "$*"
|
echo "$*"
|
||||||
echo
|
echo
|
||||||
exit 1
|
exit 1
|
||||||
}
|
} >&2
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
# OS specific support (must be 'true' or 'false').
|
||||||
cygwin=false
|
cygwin=false
|
||||||
msys=false
|
msys=false
|
||||||
darwin=false
|
darwin=false
|
||||||
nonstop=false
|
nonstop=false
|
||||||
case "`uname`" in
|
case "$( uname )" in #(
|
||||||
CYGWIN* )
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
cygwin=true
|
Darwin* ) darwin=true ;; #(
|
||||||
;;
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
Darwin* )
|
NONSTOP* ) nonstop=true ;;
|
||||||
darwin=true
|
|
||||||
;;
|
|
||||||
MINGW* )
|
|
||||||
msys=true
|
|
||||||
;;
|
|
||||||
NONSTOP* )
|
|
||||||
nonstop=true
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
@@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|||||||
if [ -n "$JAVA_HOME" ] ; then
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
else
|
else
|
||||||
JAVACMD="$JAVA_HOME/bin/java"
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
fi
|
fi
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
@@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
|
|||||||
location of your Java installation."
|
location of your Java installation."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
JAVACMD="java"
|
JAVACMD=java
|
||||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
@@ -106,80 +140,105 @@ location of your Java installation."
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
# Increase the maximum file descriptors if we can.
|
||||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
MAX_FD_LIMIT=`ulimit -H -n`
|
case $MAX_FD in #(
|
||||||
if [ $? -eq 0 ] ; then
|
max*)
|
||||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
MAX_FD="$MAX_FD_LIMIT"
|
# shellcheck disable=SC3045
|
||||||
fi
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
ulimit -n $MAX_FD
|
warn "Could not query maximum file descriptor limit"
|
||||||
if [ $? -ne 0 ] ; then
|
esac
|
||||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
case $MAX_FD in #(
|
||||||
fi
|
'' | soft) :;; #(
|
||||||
else
|
*)
|
||||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
fi
|
# shellcheck disable=SC3045
|
||||||
fi
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
# For Darwin, add options to specify how the application appears in the dock
|
|
||||||
if $darwin; then
|
|
||||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
|
||||||
|
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
|
||||||
|
|
||||||
# We build the pattern for arguments to be converted via cygpath
|
|
||||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
|
||||||
SEP=""
|
|
||||||
for dir in $ROOTDIRSRAW ; do
|
|
||||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
|
||||||
SEP="|"
|
|
||||||
done
|
|
||||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
|
||||||
# Add a user-defined pattern to the cygpath arguments
|
|
||||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
|
||||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
|
||||||
fi
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
i=0
|
|
||||||
for arg in "$@" ; do
|
|
||||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
|
||||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
|
||||||
|
|
||||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
|
||||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
|
||||||
else
|
|
||||||
eval `echo args$i`="\"$arg\""
|
|
||||||
fi
|
|
||||||
i=`expr $i + 1`
|
|
||||||
done
|
|
||||||
case $i in
|
|
||||||
0) set -- ;;
|
|
||||||
1) set -- "$args0" ;;
|
|
||||||
2) set -- "$args0" "$args1" ;;
|
|
||||||
3) set -- "$args0" "$args1" "$args2" ;;
|
|
||||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
|
||||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
|
||||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
|
||||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
|
||||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
|
||||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Escape application args
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
save () {
|
# * args from the command line
|
||||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
# * the main class name
|
||||||
echo " "
|
# * -classpath
|
||||||
}
|
# * -D...appname settings
|
||||||
APP_ARGS=`save "$@"`
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
exec "$JAVACMD" "$@"
|
||||||
|
|||||||
Vendored
+9
-6
@@ -14,7 +14,7 @@
|
|||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%" == "" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem Gradle startup script for Windows
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
if "%OS%"=="Windows_NT" setlocal
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%" == "" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
set APP_HOME=%DIRNAME%
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
|||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if "%ERRORLEVEL%" == "0" goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
rem the _cmd.exe /c_ return code!
|
rem the _cmd.exe /c_ return code!
|
||||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
exit /b 1
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
:mainEnd
|
:mainEnd
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|||||||
+14
-14
@@ -1,39 +1,39 @@
|
|||||||
ext {
|
ext {
|
||||||
minSdkVersion = 24
|
minSdkVersion = 24
|
||||||
compileSdkVersion = 33
|
compileSdkVersion = 34
|
||||||
targetSdkVersion = 32
|
targetSdkVersion = 33
|
||||||
androidxActivityVersion = '1.4.0'
|
androidxActivityVersion = '1.7.0'
|
||||||
androidxAppCompatVersion = '1.4.2'
|
androidxAppCompatVersion = '1.6.1'
|
||||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
androidxCoreVersion = '1.8.0'
|
androidxCoreVersion = '1.10.0'
|
||||||
androidPlayCore = '1.9.0'
|
androidPlayCore = '1.9.0'
|
||||||
androidxFragmentVersion = '1.4.1'
|
androidxFragmentVersion = '1.5.6'
|
||||||
junitVersion = '4.13.2'
|
junitVersion = '4.13.2'
|
||||||
androidxJunitVersion = '1.1.3'
|
androidxJunitVersion = '1.1.5'
|
||||||
androidxEspressoCoreVersion = '3.4.0'
|
androidxEspressoCoreVersion = '3.5.1'
|
||||||
cordovaAndroidVersion = '10.1.1'
|
cordovaAndroidVersion = '10.1.1'
|
||||||
androidx_car_version = '1.0.0-alpha7'
|
androidx_car_version = '1.0.0-alpha7'
|
||||||
androidx_core_ktx_version = '1.7.0'
|
androidx_core_ktx_version = '1.12.0'
|
||||||
androidx_media_version = '1.6.0'
|
androidx_media_version = '1.6.0'
|
||||||
androidx_preference_version = '1.1.1'
|
androidx_preference_version = '1.1.1'
|
||||||
androidx_test_runner_version = '1.3.0'
|
androidx_test_runner_version = '1.3.0'
|
||||||
arch_lifecycle_version = '2.2.0'
|
arch_lifecycle_version = '2.2.0'
|
||||||
constraint_layout_version = '2.0.1'
|
constraint_layout_version = '2.0.1'
|
||||||
espresso_version = '3.3.0'
|
espresso_version = '3.3.0'
|
||||||
exoplayer_version = '2.17.0'
|
exoplayer_version = '2.18.7'
|
||||||
fragment_version = '1.2.5'
|
fragment_version = '1.2.5'
|
||||||
glide_version = '4.11.0'
|
glide_version = '4.11.0'
|
||||||
gms_strict_version_matcher_version = '1.0.3'
|
gms_strict_version_matcher_version = '1.0.3'
|
||||||
gradle_version = '3.1.4'
|
gradle_version = '3.1.4'
|
||||||
gson_version = '2.8.5'
|
gson_version = '2.8.5'
|
||||||
junit_version = '4.13'
|
junit_version = '4.13'
|
||||||
kotlin_version = '1.7.20'
|
kotlin_version = '1.8.10'
|
||||||
kotlin_coroutines_version = '1.1.0'
|
kotlin_coroutines_version = '1.6.4'
|
||||||
multidex_version = '1.0.3'
|
multidex_version = '1.0.3'
|
||||||
play_services_auth_version = '18.1.0'
|
play_services_auth_version = '18.1.0'
|
||||||
recycler_view_version = '1.1.0'
|
recycler_view_version = '1.1.0'
|
||||||
robolectric_version = '4.2'
|
robolectric_version = '4.2'
|
||||||
test_runner_version = '1.1.0'
|
test_runner_version = '1.1.0'
|
||||||
coreSplashScreenVersion = '1.0.0-rc01'
|
coreSplashScreenVersion = '1.0.1'
|
||||||
androidxWebkitVersion = '1.4.0'
|
androidxWebkitVersion = '1.6.1'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,20 @@ Bookshelf Label
|
|||||||
filter: blur(20px);
|
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 {
|
.line-clamp-2 {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -129,4 +143,27 @@ Bookshelf Label
|
|||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable tr:nth-child(even) {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable tr {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable td {
|
||||||
|
padding: 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,10 @@
|
|||||||
"appId": "com.audiobookshelf.app",
|
"appId": "com.audiobookshelf.app",
|
||||||
"appName": "audiobookshelf-app",
|
"appName": "audiobookshelf-app",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"bundledWebRuntime": false
|
"bundledWebRuntime": false,
|
||||||
|
"plugins": {
|
||||||
|
"CapacitorHttp": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
castClick() {
|
castClick() {
|
||||||
if (this.$store.state.playerIsLocal) {
|
if (this.$store.getters['getIsCurrentSessionLocal']) {
|
||||||
this.$eventBus.$emit('cast-local-item')
|
this.$eventBus.$emit('cast-local-item')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="top-6 right-4 absolute cursor-pointer">
|
<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>
|
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<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 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 pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
<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">
|
<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>
|
<span v-if="!isPodcast && serverLibraryItemId && 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 -->
|
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||||
|
|
||||||
@@ -60,13 +60,13 @@
|
|||||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||||
</div>
|
</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>
|
</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 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 mx-auto" style="max-width: 414px">
|
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||||
<div class="flex items-center justify-between max-w-full">
|
<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="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>
|
<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">
|
<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">
|
||||||
@@ -90,12 +90,14 @@
|
|||||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
|
<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="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="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 ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||||
|
<div class="bg-gray-200 rounded-full w-3.5 h-3.5 pointer-events-none" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -113,7 +115,7 @@ export default {
|
|||||||
},
|
},
|
||||||
sleepTimerRunning: Boolean,
|
sleepTimerRunning: Boolean,
|
||||||
sleepTimeRemaining: Number,
|
sleepTimeRemaining: Number,
|
||||||
isServerItem: Boolean
|
serverLibraryItemId: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -272,7 +274,7 @@ export default {
|
|||||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||||
},
|
},
|
||||||
localLibraryItemCoverSrc() {
|
localLibraryItemCoverSrc() {
|
||||||
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
|
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||||
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
@@ -369,6 +371,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickChaptersBtn() {
|
||||||
|
if (!this.chapters.length) return
|
||||||
|
this.showChapterModal = true
|
||||||
|
},
|
||||||
async coverImageLoaded(fullCoverUrl) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
|
||||||
@@ -385,7 +391,7 @@ export default {
|
|||||||
},
|
},
|
||||||
clickTitleAndAuthor() {
|
clickTitleAndAuthor() {
|
||||||
if (!this.showFullscreen) return
|
if (!this.showFullscreen) return
|
||||||
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
const llid = this.serverLibraryItemId || this.libraryItem?.id || this.localLibraryItem?.id
|
||||||
if (llid) {
|
if (llid) {
|
||||||
this.$router.push(`/item/${llid}`)
|
this.$router.push(`/item/${llid}`)
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
@@ -547,7 +553,7 @@ export default {
|
|||||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||||
|
|
||||||
if (this.$refs.trackCursor) {
|
if (this.$refs.trackCursor) {
|
||||||
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px'
|
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.useChapterTrack) {
|
if (this.useChapterTrack) {
|
||||||
@@ -568,8 +574,8 @@ export default {
|
|||||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||||
|
|
||||||
if (this.$refs.playedTrack) {
|
if (this.$refs.playedTrack) {
|
||||||
var perc = time / this.totalDuration
|
const perc = time / this.totalDuration
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
const ptWidth = Math.round(perc * this.trackWidth)
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
|
|
||||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||||
@@ -689,6 +695,7 @@ export default {
|
|||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
|
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||||
} else if (action === 'total_track') {
|
} else if (action === 'total_track') {
|
||||||
this.useTotalTrack = !this.useTotalTrack
|
this.useTotalTrack = !this.useTotalTrack
|
||||||
@@ -698,6 +705,7 @@ export default {
|
|||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||||
|
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||||
} else if (action === 'close') {
|
} else if (action === 'close') {
|
||||||
this.closePlayback()
|
this.closePlayback()
|
||||||
}
|
}
|
||||||
@@ -713,7 +721,7 @@ export default {
|
|||||||
AbsAudioPlayer.closePlayback()
|
AbsAudioPlayer.closePlayback()
|
||||||
},
|
},
|
||||||
endPlayback() {
|
endPlayback() {
|
||||||
this.$store.commit('setPlayerItem', null)
|
this.$store.commit('setPlaybackSession', null)
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
this.isEnded = false
|
this.isEnded = false
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
@@ -759,7 +767,7 @@ export default {
|
|||||||
this.isEnded = false
|
this.isEnded = false
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
this.syncStatus = 0
|
this.syncStatus = 0
|
||||||
this.$store.commit('setPlayerItem', this.playbackSession)
|
this.$store.commit('setPlaybackSession', this.playbackSession)
|
||||||
|
|
||||||
// Set track width
|
// Set track width
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -786,6 +794,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||||
|
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
|
||||||
this.lockUi = await this.$localStore.getPlayerLock()
|
this.lockUi = await this.$localStore.getPlayerLock()
|
||||||
|
|
||||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<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" />
|
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :serverLibraryItemId="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-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" :is-auto="isAutoSleepTimer" @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" />
|
||||||
@@ -110,7 +110,7 @@ export default {
|
|||||||
},
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
var chunks = data.chunks
|
const chunks = data.chunks
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
}
|
}
|
||||||
@@ -214,6 +214,8 @@ export default {
|
|||||||
if (startTime !== undefined && startTime !== null) {
|
if (startTime !== undefined && startTime !== null) {
|
||||||
// seek to start time
|
// seek to start time
|
||||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||||
|
} else if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.play()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -254,7 +256,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
|
if (this.$refs.audioPlayer && this.$refs.audioPlayer.isPlaying) {
|
||||||
this.$refs.audioPlayer.pause()
|
this.$refs.audioPlayer.pause()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -263,8 +265,7 @@ export default {
|
|||||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
},
|
},
|
||||||
onMediaPlayerChanged(data) {
|
onMediaPlayerChanged(data) {
|
||||||
var mediaPlayer = data.value
|
this.$store.commit('setMediaPlayer', data.value)
|
||||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
|
||||||
},
|
},
|
||||||
onReady() {
|
onReady() {
|
||||||
// The UI is reporting elsewhere we are ready
|
// The UI is reporting elsewhere we are ready
|
||||||
@@ -281,6 +282,56 @@ export default {
|
|||||||
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
||||||
AbsAudioPlayer.onReady()
|
AbsAudioPlayer.onReady()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
playbackTimeUpdate(currentTime) {
|
||||||
|
this.$refs.audioPlayer?.seek(currentTime)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* When device gains focus then refresh the timestamps in the audio player
|
||||||
|
*/
|
||||||
|
deviceFocused(hasFocus) {
|
||||||
|
if (!this.$store.state.currentPlaybackSession) return
|
||||||
|
|
||||||
|
if (hasFocus) {
|
||||||
|
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||||
|
const playbackSession = this.$store.state.currentPlaybackSession
|
||||||
|
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
|
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
||||||
|
const localEpisodeId = playbackSession.localEpisodeId
|
||||||
|
if (!localLibraryItemId) {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
||||||
|
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
||||||
|
return mp.localLibraryItemId === localLibraryItemId
|
||||||
|
})
|
||||||
|
if (localMediaProgress) {
|
||||||
|
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||||
|
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||||
|
this.$refs.audioPlayer.timeupdate()
|
||||||
|
} else {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const libraryItemId = playbackSession.libraryItemId
|
||||||
|
const episodeId = playbackSession.episodeId
|
||||||
|
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||||
|
this.$nativeHttp
|
||||||
|
.get(url)
|
||||||
|
.then((data) => {
|
||||||
|
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
||||||
|
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||||
|
this.$refs.audioPlayer.currentTime = data.currentTime
|
||||||
|
this.$refs.audioPlayer.timeupdate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -298,6 +349,8 @@ export default {
|
|||||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||||
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||||
@@ -311,6 +364,8 @@ export default {
|
|||||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||||
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -11,7 +11,11 @@
|
|||||||
|
|
||||||
<div class="w-full overflow-y-auto">
|
<div class="w-full overflow-y-auto">
|
||||||
<template v-for="item in navItems">
|
<template v-for="item in navItems">
|
||||||
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
|
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" @click="clickAction(item.action)">
|
||||||
|
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||||
|
<p class="pl-4">{{ item.text }}</p>
|
||||||
|
</button>
|
||||||
|
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
|
||||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||||
<p class="pl-4">{{ item.text }}</p>
|
<p class="pl-4">{{ item.text }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -24,9 +28,9 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-xs">{{ $config.version }}</p>
|
<p class="text-xs">{{ $config.version }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div v-if="user" class="flex items-center" @click="logout">
|
<div v-if="user" class="flex items-center" @click="disconnect">
|
||||||
<p class="text-xs pr-2">Logout</p>
|
<p class="text-xs pr-2">Disconnect</p>
|
||||||
<span class="material-icons text-sm">logout</span>
|
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,6 +130,15 @@ export default {
|
|||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
to: '/settings'
|
to: '/settings'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (this.serverConnectionConfig) {
|
||||||
|
items.push({
|
||||||
|
icon: 'login',
|
||||||
|
text: 'Switch Server/User',
|
||||||
|
action: 'logout'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
currentRoutePath() {
|
currentRoutePath() {
|
||||||
@@ -133,14 +146,20 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async clickAction(action) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
if (action === 'logout') {
|
||||||
|
await this.logout()
|
||||||
|
this.$router.push('/connect')
|
||||||
|
}
|
||||||
|
},
|
||||||
clickBackground() {
|
clickBackground() {
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
await this.$hapticsImpact()
|
|
||||||
if (this.user) {
|
if (this.user) {
|
||||||
await this.$axios.$post('/logout').catch((error) => {
|
await this.$nativeHttp.post('/logout').catch((error) => {
|
||||||
console.error(error)
|
console.error('Failed to logout', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +167,17 @@ export default {
|
|||||||
await this.$db.logout()
|
await this.$db.logout()
|
||||||
this.$localStore.removeLastLibraryId()
|
this.$localStore.removeLastLibraryId()
|
||||||
this.$store.commit('user/logout')
|
this.$store.commit('user/logout')
|
||||||
this.$router.push('/connect')
|
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||||
|
},
|
||||||
|
async disconnect() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
await this.logout()
|
||||||
|
|
||||||
|
if (this.$route.name !== 'bookshelf') {
|
||||||
|
this.$router.replace('/bookshelf')
|
||||||
|
} else {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
touchstart(e) {
|
touchstart(e) {
|
||||||
this.touchEvent = new TouchEvent(e)
|
this.touchEvent = new TouchEvent(e)
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ export default {
|
|||||||
|
|
||||||
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
||||||
|
|
||||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
const payload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||||
console.error('failed to fetch books', error)
|
console.error('failed to fetch books', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
@@ -332,6 +332,12 @@ export default {
|
|||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
if (this.isFirstInit) return
|
if (this.isFirstInit) return
|
||||||
|
if (!this.user) {
|
||||||
|
// Offline support not available
|
||||||
|
await this.resetEntities()
|
||||||
|
this.$eventBus.$emit('bookshelf-total-entities', 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
|
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
|
||||||
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
|
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
|
||||||
|
|||||||
@@ -58,24 +58,7 @@ export default {
|
|||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {},
|
||||||
async searchAuthor() {
|
|
||||||
this.searching = true
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
if (!response) {
|
|
||||||
this.$toast.error('Author not found')
|
|
||||||
} else if (response.updated) {
|
|
||||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
|
||||||
else this.$toast.success('Author was updated (no image found)')
|
|
||||||
} else {
|
|
||||||
this.$toast.info('No updates were made for Author')
|
|
||||||
}
|
|
||||||
this.searching = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -7,26 +7,6 @@
|
|||||||
<p class="truncate text-sm">{{ name }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -78,11 +78,14 @@ export default {
|
|||||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||||
html += lastPart
|
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 === '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 === 'authors') return `by ${html}`
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
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 === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${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}`
|
return `${html}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,12 @@
|
|||||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
<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" 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>
|
||||||
|
|
||||||
|
<div v-if="showPlayButton" class="absolute -bottom-16 -right-16 rotate-45 w-32 h-32 p-2 bg-gradient-to-r from-transparent to-black to-40% inline-flex justify-start items-center">
|
||||||
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto -rotate-45" @click.stop.prevent="play">
|
||||||
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Play/pause button for podcast episode -->
|
<!-- Play/pause button for podcast episode -->
|
||||||
@@ -47,6 +53,7 @@
|
|||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- No progress shown for collapsed series in library -->
|
||||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
|
<!-- Downloaded icon -->
|
||||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,20 +63,32 @@
|
|||||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- rss feed icon -->
|
||||||
|
<div v-if="rssFeed" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||||
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- Series sequence -->
|
||||||
<div v-if="seriesSequence && showSequence && !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` }">
|
<div v-if="seriesSequence && showSequence && !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' }">#{{ seriesSequence }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- 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` }">
|
<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 #{{ recentEpisodeNumber }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||||
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
<div v-else-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
<div v-else-if="numEpisodes && !numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast Num Episodes Incomplete -->
|
||||||
|
<div v-else-if="numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -104,7 +123,6 @@ export default {
|
|||||||
isProcessingReadUpdate: false,
|
isProcessingReadUpdate: false,
|
||||||
libraryItem: null,
|
libraryItem: null,
|
||||||
imageReady: false,
|
imageReady: false,
|
||||||
rescanning: false,
|
|
||||||
selected: false,
|
selected: false,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
showCoverBg: false,
|
showCoverBg: false,
|
||||||
@@ -172,6 +190,10 @@ export default {
|
|||||||
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||||
return this.media.numEpisodes
|
return this.media.numEpisodes
|
||||||
},
|
},
|
||||||
|
numEpisodesIncomplete() {
|
||||||
|
if (this.isLocal) return 0
|
||||||
|
return this._libraryItem.numEpisodesIncomplete || 0
|
||||||
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.store.state.processingBatch
|
return this.store.state.processingBatch
|
||||||
},
|
},
|
||||||
@@ -210,7 +232,7 @@ export default {
|
|||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
return this.series ? this.series.sequence : null
|
return this.series?.sequence || null
|
||||||
},
|
},
|
||||||
recentEpisode() {
|
recentEpisode() {
|
||||||
// Only added to item when getting currently listening podcasts
|
// Only added to item when getting currently listening podcasts
|
||||||
@@ -221,7 +243,7 @@ export default {
|
|||||||
if (this.recentEpisode.episode) {
|
if (this.recentEpisode.episode) {
|
||||||
return this.recentEpisode.episode.replace(/^#/, '')
|
return this.recentEpisode.episode.replace(/^#/, '')
|
||||||
}
|
}
|
||||||
return this.recentEpisode.index
|
return ''
|
||||||
},
|
},
|
||||||
collapsedSeries() {
|
collapsedSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
@@ -229,14 +251,14 @@ export default {
|
|||||||
},
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
return this.collapsedSeries?.numBooks || 0
|
||||||
},
|
},
|
||||||
seriesSequenceList() {
|
seriesSequenceList() {
|
||||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
return this.collapsedSeries?.seriesSequenceList || null
|
||||||
},
|
},
|
||||||
libraryItemIdsInSeries() {
|
libraryItemIdsInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
return this.collapsedSeries?.libraryItemIds || []
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.recentEpisode) return this.recentEpisode.title
|
if (this.recentEpisode) return this.recentEpisode.title
|
||||||
@@ -274,35 +296,34 @@ export default {
|
|||||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
|
useEBookProgress() {
|
||||||
|
if (!this.userProgress || this.userProgress.progress) return false
|
||||||
|
return this.userProgress.ebookProgress > 0
|
||||||
|
},
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||||
|
return Math.max(Math.min(1, this.userProgress?.progress || 0), 0) || 0
|
||||||
},
|
},
|
||||||
itemIsFinished() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return !!this.userProgress?.isFinished
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
playerIsLocal() {
|
|
||||||
return !!this.$store.state.playerIsLocal
|
|
||||||
},
|
|
||||||
localLibraryItemId() {
|
localLibraryItemId() {
|
||||||
if (this.isLocal) return this.libraryItemId
|
if (this.isLocal) return this.libraryItemId
|
||||||
return this.localLibraryItem ? this.localLibraryItem.id : null
|
return this.localLibraryItem?.id || null
|
||||||
|
},
|
||||||
|
localEpisode() {
|
||||||
|
if (!this.recentEpisode || !this.localLibraryItem) return null
|
||||||
|
// Current recentEpisode is only implemented server side so this will always be the serverEpisodeId
|
||||||
|
return this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
if (this.isPodcast) {
|
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode?.id)
|
||||||
if (this.playerIsLocal) {
|
|
||||||
// Check is streaming local version of this episode
|
|
||||||
return false // episode cards not implemented for local yet
|
|
||||||
}
|
|
||||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
|
|
||||||
} else {
|
|
||||||
return false // not yet necessary for books
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
streamIsPlaying() {
|
streamIsPlaying() {
|
||||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
return this.store.state.playerIsPlaying && this.isStreaming
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -372,13 +393,20 @@ export default {
|
|||||||
showHasLocalDownload() {
|
showHasLocalDownload() {
|
||||||
if (this.localLibraryItem || this.isLocal) {
|
if (this.localLibraryItem || this.isLocal) {
|
||||||
if (this.recentEpisode && !this.isLocal) {
|
if (this.recentEpisode && !this.isLocal) {
|
||||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
return !!this.localEpisode
|
||||||
return !!localEpisode
|
|
||||||
} else {
|
} else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
},
|
||||||
|
rssFeed() {
|
||||||
|
if (this.booksInSeries) return null
|
||||||
|
return this._libraryItem.rssFeed || null
|
||||||
|
},
|
||||||
|
showPlayButton() {
|
||||||
|
return false
|
||||||
|
// return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -420,6 +448,7 @@ export default {
|
|||||||
// Server books may have a local library item
|
// Server books may have a local library item
|
||||||
this.localLibraryItem = localLibraryItem
|
this.localLibraryItem = localLibraryItem
|
||||||
},
|
},
|
||||||
|
async play() {},
|
||||||
async playEpisode() {
|
async playEpisode() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
@@ -428,18 +457,15 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.localLibraryItem) {
|
if (this.localEpisode) {
|
||||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
// Play episode locally
|
||||||
if (localEpisode) {
|
eventBus.$emit('play-item', {
|
||||||
// Play episode locally
|
libraryItemId: this.localLibraryItemId,
|
||||||
eventBus.$emit('play-item', {
|
episodeId: this.localEpisode.id,
|
||||||
libraryItemId: this.localLibraryItemId,
|
serverLibraryItemId: this.libraryItemId,
|
||||||
episodeId: localEpisode.id,
|
serverEpisodeId: this.recentEpisode.id
|
||||||
serverLibraryItemId: this.libraryItemId,
|
})
|
||||||
serverEpisodeId: this.recentEpisode.id
|
return
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||||
@@ -461,48 +487,6 @@ export default {
|
|||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.libraryItem)
|
this.$emit('edit', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleFinished() {
|
|
||||||
var updatePayload = {
|
|
||||||
isFinished: !this.itemIsFinished
|
|
||||||
}
|
|
||||||
this.isProcessingReadUpdate = true
|
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
|
||||||
var axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
rescan() {
|
|
||||||
this.rescanning = true
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
|
||||||
.then((data) => {
|
|
||||||
this.rescanning = false
|
|
||||||
var result = data.result
|
|
||||||
if (!result) {
|
|
||||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
this.$toast.error(`Re-Scan complete item was removed`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to scan library item', error)
|
|
||||||
this.$toast.error('Failed to scan library item')
|
|
||||||
this.rescanning = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
showEditModalTracks() {
|
showEditModalTracks() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export default {
|
|||||||
isProcessingReadUpdate: false,
|
isProcessingReadUpdate: false,
|
||||||
libraryItem: null,
|
libraryItem: null,
|
||||||
imageReady: false,
|
imageReady: false,
|
||||||
rescanning: false,
|
|
||||||
selected: false,
|
selected: false,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
showCoverBg: false,
|
showCoverBg: false,
|
||||||
@@ -349,48 +348,6 @@ export default {
|
|||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.libraryItem)
|
this.$emit('edit', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleFinished() {
|
|
||||||
var updatePayload = {
|
|
||||||
isFinished: !this.itemIsFinished
|
|
||||||
}
|
|
||||||
this.isProcessingReadUpdate = true
|
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
|
||||||
var axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
rescan() {
|
|
||||||
this.rescanning = true
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
|
||||||
.then((data) => {
|
|
||||||
this.rescanning = false
|
|
||||||
var result = data.result
|
|
||||||
if (!result) {
|
|
||||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
this.$toast.error(`Re-Scan complete item was removed`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to scan library item', error)
|
|
||||||
this.$toast.error('Failed to scan library item')
|
|
||||||
this.rescanning = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
showEditModalTracks() {
|
showEditModalTracks() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="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">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +55,27 @@ export default {
|
|||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.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() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$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>
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -217,27 +218,57 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pingServerAddress(address, customHeaders) {
|
async getRequest(url, headers, connectTimeout = 6000) {
|
||||||
const options = { timeout: 3000 }
|
const options = {
|
||||||
if (customHeaders) {
|
url,
|
||||||
options.headers = customHeaders
|
headers,
|
||||||
|
connectTimeout
|
||||||
}
|
}
|
||||||
return this.$axios
|
const response = await CapacitorHttp.get(options)
|
||||||
.$get(`${address}/ping`, options)
|
console.log('[ServerConnectForm] GET request response', response)
|
||||||
.then((data) => data.success)
|
if (response.status >= 400) {
|
||||||
|
throw new Error(response.data)
|
||||||
|
} else {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async postRequest(url, data, headers, connectTimeout = 6000) {
|
||||||
|
if (!headers) headers = {}
|
||||||
|
if (!headers['Content-Type'] && data) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
data,
|
||||||
|
connectTimeout
|
||||||
|
}
|
||||||
|
const response = await CapacitorHttp.post(options)
|
||||||
|
console.log('[ServerConnectForm] POST request response', response)
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error(response.data)
|
||||||
|
} else {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pingServerAddress(address, customHeaders) {
|
||||||
|
return this.getRequest(`${address}/ping`, customHeaders)
|
||||||
|
.then((data) => {
|
||||||
|
return data.success
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Server check failed', error)
|
console.error('Server ping failed', error)
|
||||||
|
const errorMsg = error.message || error
|
||||||
this.error = 'Failed to ping server'
|
this.error = 'Failed to ping server'
|
||||||
|
if (typeof errorMsg === 'string') {
|
||||||
|
this.error += ` (${errorMsg})`
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
requestServerLogin() {
|
requestServerLogin() {
|
||||||
const options = {}
|
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, this.serverConfig.customHeaders, 20000)
|
||||||
if (this.serverConfig.customHeaders) {
|
|
||||||
options.headers = this.serverConfig.customHeaders
|
|
||||||
}
|
|
||||||
return this.$axios
|
|
||||||
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.user) {
|
if (!data.user) {
|
||||||
console.error(data.error)
|
console.error(data.error)
|
||||||
@@ -248,8 +279,11 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Server auth failed', error)
|
console.error('Server auth failed', error)
|
||||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
const errorMsg = error.message || error
|
||||||
this.error = errorMsg
|
this.error = 'Failed to login'
|
||||||
|
if (typeof errorMsg === 'string') {
|
||||||
|
this.error += ` (${errorMsg})`
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -330,12 +364,19 @@ export default {
|
|||||||
|
|
||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var authRes = await this.$axios.$post(`${this.serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${this.serverConfig.token}` } }).catch((error) => {
|
|
||||||
console.error('[Server] Server auth failed', error)
|
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
||||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
console.error('[ServerConnectForm] Server auth failed', error)
|
||||||
this.error = errorMsg
|
const errorMsg = error.message || error
|
||||||
|
this.error = 'Failed to authorize'
|
||||||
|
if (typeof errorMsg === 'string') {
|
||||||
|
this.error += ` (${errorMsg})`
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('[ServerConnectForm] authRes=', authRes)
|
||||||
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
return authRes
|
return authRes
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default {
|
|||||||
return this.libraryItem.isLocal
|
return this.libraryItem.isLocal
|
||||||
},
|
},
|
||||||
localCover() {
|
localCover() {
|
||||||
return this.libraryItem ? this.libraryItem.coverContentUrl : null
|
return this.libraryItem?.coverContentUrl || null
|
||||||
},
|
},
|
||||||
squareAspectRatio() {
|
squareAspectRatio() {
|
||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ export default {
|
|||||||
console.log('Podcast payload', podcastPayload)
|
console.log('Podcast payload', podcastPayload)
|
||||||
|
|
||||||
this._processing = true
|
this._processing = true
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post('/api/podcasts', podcastPayload)
|
.post('/api/podcasts', podcastPayload)
|
||||||
.then((libraryItem) => {
|
.then((libraryItem) => {
|
||||||
this._processing = false
|
this._processing = false
|
||||||
this.$toast.success('Podcast added')
|
this.$toast.success('Podcast added')
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" :width="200" height="100%">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-8 left-4 z-40">
|
||||||
|
<p class="text-white text-2xl truncate">Auto Rewind Time</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||||
|
@click="
|
||||||
|
show = false
|
||||||
|
manualTimerModal = false
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
|
<div v-if="manualTimerModal" class="p-4">
|
||||||
|
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||||
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex my-2 justify-between">
|
||||||
|
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||||
|
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||||
|
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||||
|
</div>
|
||||||
|
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||||
|
</div>
|
||||||
|
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="timeout in timeouts">
|
||||||
|
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
manualTimerModal: null,
|
||||||
|
manualTimeoutMin: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeouts() {
|
||||||
|
return [5, 10, 15, 30, 45, 60, 90]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async clickedChapterOption() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
this.show = false
|
||||||
|
this.$nextTick(() => this.$emit('change', 0))
|
||||||
|
},
|
||||||
|
async clickedOption(timeoutMin) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
const timeout = timeoutMin * 1000 * 60
|
||||||
|
this.show = false
|
||||||
|
this.manualTimerModal = false
|
||||||
|
this.$nextTick(() => this.$emit('change', timeout))
|
||||||
|
},
|
||||||
|
async increaseManualTimeout() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
this.manualTimeoutMin++
|
||||||
|
},
|
||||||
|
async decreaseManualTimeout() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" height="100%">
|
<modals-modal v-model="show" :width="400" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<div class="absolute top-8 left-4 z-40">
|
||||||
<p class="text-white text-2xl truncate">Bookmarks</p>
|
<p class="text-white text-2xl truncate">Bookmarks</p>
|
||||||
@@ -102,8 +102,8 @@ export default {
|
|||||||
})
|
})
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
.delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark removed')
|
this.$toast.success('Bookmark removed')
|
||||||
})
|
})
|
||||||
@@ -119,8 +119,8 @@ export default {
|
|||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
var bookmark = { ...updatedBookmark }
|
var bookmark = { ...updatedBookmark }
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark updated')
|
this.$toast.success('Bookmark updated')
|
||||||
})
|
})
|
||||||
@@ -134,12 +134,12 @@ export default {
|
|||||||
if (!this.newBookmarkTitle) {
|
if (!this.newBookmarkTitle) {
|
||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||||
}
|
}
|
||||||
var bookmark = {
|
const bookmark = {
|
||||||
title: this.newBookmarkTitle,
|
title: this.newBookmarkTitle,
|
||||||
time: Math.floor(this.currentTime)
|
time: Math.floor(this.currentTime)
|
||||||
}
|
}
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark added')
|
this.$toast.success('Bookmark added')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="350" height="100%">
|
<modals-modal v-model="show" :width="400" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="currentChapter" class="absolute top-8 left-4 z-40 pt-0.5" style="max-width: 80%">
|
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||||
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
|
<p class="text-white text-lg truncate">{{ chapters.length }} Chapters</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="(chapter, index) in chapters">
|
<template v-for="(chapter, index) in chapters">
|
||||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||||
<div class="relative flex items-center pl-3 pr-20">
|
<div class="relative flex items-center pl-3 pr-20">
|
||||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
|
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
|
||||||
<div class="absolute top-0 right-3 -mt-0.5">
|
<div class="absolute top-0 right-3 -mt-0.5">
|
||||||
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
|
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,14 +38,17 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(newVal) {
|
value(newVal) {
|
||||||
this.$nextTick(this.scrollToChapter)
|
if (newVal) {
|
||||||
|
this.$nextTick(this.scrollToChapter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -57,11 +60,15 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
_playbackRate() {
|
||||||
|
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||||
|
return this.playbackRate
|
||||||
|
},
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter ? this.currentChapter.id : null
|
return this.currentChapter?.id
|
||||||
},
|
},
|
||||||
currentChapterTitle() {
|
currentChapterTitle() {
|
||||||
return this.currentChapter ? this.currentChapter.title : null
|
return this.currentChapter?.title || null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -71,12 +78,12 @@ export default {
|
|||||||
scrollToChapter() {
|
scrollToChapter() {
|
||||||
if (!this.currentChapterId) return
|
if (!this.currentChapterId) return
|
||||||
|
|
||||||
var container = this.$refs.container
|
const container = this.$refs.container
|
||||||
if (container) {
|
if (container) {
|
||||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||||
if (currChapterEl) {
|
if (currChapterEl) {
|
||||||
var offsetTop = currChapterEl.offsetTop
|
const offsetTop = currChapterEl.offsetTop
|
||||||
var containerHeight = container.clientHeight
|
const containerHeight = container.clientHeight
|
||||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" height="100%">
|
<modals-modal v-model="show" :width="width" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||||
<p class="text-white text-xl truncate">{{ title }}</p>
|
<p class="text-white text-xl truncate">{{ title }}</p>
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white/20 p-2" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in itemsToShow">
|
||||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" :ref="`item-${item.value}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="relative flex items-center px-3">
|
<div class="relative flex items-center px-3">
|
||||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||||
@@ -34,11 +34,27 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
selected: String // optional
|
selected: [String, Number], // optional
|
||||||
|
itemPaddingY: {
|
||||||
|
type: String,
|
||||||
|
default: '16px'
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 300
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.$nextTick(this.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
@@ -47,11 +63,32 @@ export default {
|
|||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
itemsToShow() {
|
||||||
|
return this.items.map((i) => {
|
||||||
|
if (typeof i === 'string') {
|
||||||
|
return {
|
||||||
|
text: i,
|
||||||
|
value: i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(action) {
|
clickedOption(action) {
|
||||||
this.$emit('action', action)
|
this.$emit('action', action)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (this.selected && this.$refs[`item-${this.selected}`]?.[0]) {
|
||||||
|
// Set scroll position so that selected item is in the center
|
||||||
|
const containerOffset = this.$refs.container.offsetTop + this.$refs.container.clientHeight / 2
|
||||||
|
const scrollAmount = this.$refs[`item-${this.selected}`][0].offsetTop - containerOffset
|
||||||
|
this.$refs.container.scrollTo({
|
||||||
|
top: scrollAmount
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" width="90%" height="100%">
|
<modals-modal v-model="show" width="90%" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-show="selected !== 'all'" class="absolute top-6 left-4 z-40">
|
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
||||||
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||||
|
<div ref="content" class="relative text-white h-full w-full bg-bg">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
processing: Boolean,
|
||||||
|
halfScreen: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.setShow()
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setShow() {
|
||||||
|
this.$store.commit('globals/setIsModalOpen', true)
|
||||||
|
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content.style.transform = 'translateY(0)'
|
||||||
|
}, 10)
|
||||||
|
document.documentElement.classList.add('modal-open')
|
||||||
|
},
|
||||||
|
setHide() {
|
||||||
|
this.$store.commit('globals/setIsModalOpen', false)
|
||||||
|
|
||||||
|
this.content.style.transform = 'translateY(100vh)'
|
||||||
|
setTimeout(() => {
|
||||||
|
this.el.remove()
|
||||||
|
document.documentElement.classList.remove('modal-open')
|
||||||
|
}, 250)
|
||||||
|
},
|
||||||
|
closeModalEvt() {
|
||||||
|
console.log('Close modal event')
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('close-modal', this.closeModalEvt)
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
this.content.style.transform = 'translateY(100vh)'
|
||||||
|
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||||
|
this.el.remove()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
<div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="library in libraries">
|
<template v-for="library in libraries">
|
||||||
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
|
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer" :class="currentLibraryId === library.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
|
||||||
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
|
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
|
||||||
<div class="flex items-center px-3">
|
<div class="flex items-center px-3">
|
||||||
<ui-library-icon :icon="library.icon" />
|
<ui-library-icon :icon="library.icon" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<div class="absolute z-40 top-6 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
<div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||||
<span class="material-icons text-4xl">close</span>
|
<span class="material-icons text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
orderBy: String,
|
orderBy: String,
|
||||||
descending: Boolean
|
descending: Boolean,
|
||||||
|
episodes: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -89,6 +90,24 @@ export default {
|
|||||||
text: 'File Modified',
|
text: 'File Modified',
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
episodeItems: [
|
||||||
|
{
|
||||||
|
text: 'Pub Date',
|
||||||
|
value: 'publishedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Title',
|
||||||
|
value: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Season',
|
||||||
|
value: 'season'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Episode',
|
||||||
|
value: 'episode'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -121,6 +140,7 @@ export default {
|
|||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||||
},
|
},
|
||||||
items() {
|
items() {
|
||||||
|
if (this.episodes) return this.episodeItems
|
||||||
if (this.isPodcast) return this.podcastItems
|
if (this.isPodcast) return this.podcastItems
|
||||||
return this.bookItems
|
return this.bookItems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
.post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.success('Started downloading episodes on server')
|
this.$toast.success('Started downloading episodes on server')
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" height="100%">
|
<modals-modal v-model="show" :width="300" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
<div class="absolute top-10 left-4 z-40" style="max-width: 80%">
|
||||||
<p class="text-white text-lg truncate">Select Local Folder</p>
|
<p class="text-white text-lg truncate">Select Download Location</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="folder in localFolders">
|
<template v-for="folder in localFolders">
|
||||||
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-4" role="option" @click="clickedOption(folder)">
|
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-5" role="option" @click="clickedOption(folder)">
|
||||||
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
|
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
|
||||||
|
<span class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">folder</span>
|
||||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
|
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -24,36 +25,56 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
mediaType: String
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localFolders: []
|
localFolders: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(newVal) {
|
show(newVal) {
|
||||||
this.$nextTick(this.init)
|
if (newVal) {
|
||||||
|
this.$nextTick(this.init)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
return this.value
|
return this.$store.state.globals.showSelectLocalFolderModal
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$store.commit('globals/setShowSelectLocalFolderModal', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
modalData() {
|
||||||
|
return this.$store.state.globals.localFolderSelectData || {}
|
||||||
|
},
|
||||||
|
callback() {
|
||||||
|
return this.modalData.callback
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.modalData.mediaType
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(folder) {
|
clickedOption(folder) {
|
||||||
this.$emit('select', folder)
|
this.show = false
|
||||||
|
if (!this.callback) {
|
||||||
|
console.error('Callback not set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.callback(folder)
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
|
|
||||||
|
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
|
||||||
|
localFolders.push({
|
||||||
|
id: `internal-${this.mediaType}`,
|
||||||
|
name: 'Internal App Storage',
|
||||||
|
mediaType: this.mediaType
|
||||||
|
})
|
||||||
|
}
|
||||||
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
|
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-3 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-1 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
||||||
<span class="material-icons text-xl" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
<div class="flex-grow overflow-hidden">
|
<div class="flex items-center mb-0.5">
|
||||||
<p class="pl-2 pr-2 truncate text-sm">{{ bookmark.title }}</p>
|
<i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-white/60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
|
||||||
|
<p class="truncate text-sm">
|
||||||
|
{{ bookmark.title }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-mono text-gray-300 flex items-center"><span class="material-icons text-base text-white/60 pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full flex items-center w-14 justify-end">
|
<div class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
|
||||||
<span class="font-mono text-xs text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
|
<span class="material-icons text-2xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||||
</div>
|
<span class="material-icons text-2xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||||
<div class="h-full flex items-center justify-end transform w-16">
|
|
||||||
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
|
||||||
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,41 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="360" height="100%" :processing="processing">
|
<modals-fullscreen-modal v-model="show" :processing="processing">
|
||||||
<template #outer>
|
<div class="flex items-end justify-between h-16 px-4 pb-2">
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<h1 class="text-lg">Add to Playlist</h1>
|
||||||
<p class="text-white text-2xl truncate">Add to Playlist</p>
|
<button class="flex" @click="show = false">
|
||||||
</div>
|
<span class="material-icons">close</span>
|
||||||
</template>
|
</button>
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
</div>
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
|
|
||||||
<div class="w-full h-full p-4" v-show="showPlaylistNameInput">
|
|
||||||
<div class="flex mb-4 items-center">
|
|
||||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
|
|
||||||
<span class="material-icons text-3xl">arrow_back</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl pl-2">New Playlist</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
|
<!-- create new playlist form -->
|
||||||
<div class="flex justify-end mt-6">
|
<div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-128px)] flex items-center">
|
||||||
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
|
<div class="w-full px-4">
|
||||||
|
<div class="flex mb-4 items-center">
|
||||||
|
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
|
||||||
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xl pl-2">New Playlist</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-full" v-show="!showPlaylistNameInput">
|
|
||||||
<template v-for="playlist in sortedPlaylists">
|
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
|
||||||
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" />
|
<div class="flex justify-end mt-6">
|
||||||
</template>
|
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
|
||||||
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
|
|
||||||
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
|
|
||||||
</div>
|
|
||||||
<ui-btn :loading="processing" color="success" class="w-full flex items-center justify-center" @click.stop="createPlaylist">
|
|
||||||
<span class="material-icons text-xl">add</span>
|
|
||||||
<p class="text-base pl-2">New Playlist</p>
|
|
||||||
</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
|
||||||
|
<!-- playlists list -->
|
||||||
|
<div v-if="!showPlaylistNameInput" class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-128px)]">
|
||||||
|
<div class="w-full h-full" v-show="!showPlaylistNameInput">
|
||||||
|
<template v-for="playlist in sortedPlaylists">
|
||||||
|
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" @close="show = false" />
|
||||||
|
</template>
|
||||||
|
<div v-if="!playlists.length" class="flex h-full items-center justify-center">
|
||||||
|
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- create playlist btn -->
|
||||||
|
<div v-if="!showPlaylistNameInput" class="flex items-start justify-between h-16 pt-2 absolute bottom-0 left-0 w-full">
|
||||||
|
<ui-btn :loading="processing" color="success" class="w-full h-full flex items-center justify-center" @click.stop="createPlaylist">
|
||||||
|
<p class="text-base">Create New Playlist</p>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</modals-fullscreen-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -101,8 +109,8 @@ export default {
|
|||||||
},
|
},
|
||||||
loadPlaylists() {
|
loadPlaylists() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
|
.get(`/api/libraries/${this.currentLibraryId}/playlists`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.playlists = data.results || []
|
this.playlists = data.results || []
|
||||||
})
|
})
|
||||||
@@ -127,11 +135,10 @@ export default {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
.post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||||
.then((updatedPlaylist) => {
|
.then((updatedPlaylist) => {
|
||||||
console.log(`Items removed from playlist`, updatedPlaylist)
|
console.log(`Items removed from playlist`, updatedPlaylist)
|
||||||
this.$toast.success('Playlist item(s) removed')
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove items from playlist', error)
|
console.error('Failed to remove items from playlist', error)
|
||||||
@@ -146,11 +153,10 @@ export default {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
.post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
||||||
.then((updatedPlaylist) => {
|
.then((updatedPlaylist) => {
|
||||||
console.log(`Items added to playlist`, updatedPlaylist)
|
console.log(`Items added to playlist`, updatedPlaylist)
|
||||||
this.$toast.success('Items added to playlist')
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to add items to playlist', error)
|
console.error('Failed to add items to playlist', error)
|
||||||
@@ -178,11 +184,10 @@ export default {
|
|||||||
name: this.newPlaylistName
|
name: this.newPlaylistName
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post('/api/playlists', newPlaylist)
|
.post('/api/playlists', newPlaylist)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
console.log('New playlist created', data)
|
console.log('New playlist created', data)
|
||||||
this.$toast.success(`Playlist "${data.name}" created`)
|
|
||||||
this.newPlaylistName = ''
|
this.newPlaylistName = ''
|
||||||
this.showPlaylistNameInput = false
|
this.showPlaylistNameInput = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start cursor-pointer relative" :class="inPlaylist ? 'bg-bg bg-opacity-60' : 'bg-opacity-20'">
|
<div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start relative border-y border-white/5" :class="inPlaylist ? 'bg-primary/20' : ''">
|
||||||
<div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
<div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-14 min-w-[56px] text-center" @click.stop="clickCover">
|
||||||
<covers-playlist-cover :items="items" :width="52" :height="52" />
|
<covers-playlist-cover :items="items" :width="52" :height="52" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow overflow-hidden">
|
<div class="flex-grow overflow-hidden">
|
||||||
<p class="pl-1 pr-2 truncate text-sm">{{ playlist.name }}</p>
|
<p class="px-2 truncate text-sm">{{ playlist.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-24 min-w-[96px] px-1">
|
||||||
<div class="absolute top-0 right-0 h-full flex items-center px-3" @click.stop="click">
|
<ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">Remove</ui-btn>
|
||||||
<span v-if="inPlaylist" class="material-icons text-error">remove</span>
|
<ui-btn v-else small class="w-full" @click.stop="click">Add</ui-btn>
|
||||||
<span v-else class="material-icons text-success">add</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -35,6 +34,10 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
click() {
|
click() {
|
||||||
this.$emit('click', this.playlist)
|
this.$emit('click', this.playlist)
|
||||||
|
},
|
||||||
|
clickCover() {
|
||||||
|
this.$emit('close')
|
||||||
|
this.$router.push(`/playlist/${this.playlist.id}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full py-2 text-sm">
|
||||||
|
<div class="flex -mb-px">
|
||||||
|
<div class="w-1/2 h-6 rounded-tl-md relative border border-white/10 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = false">
|
||||||
|
<p class="text-sm">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2 h-6 rounded-tr-md relative border border-white/10 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = true">
|
||||||
|
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-2 py-4 md:p-4 border border-white/10 rounded-b-md mr-px" style="min-height: 220px">
|
||||||
|
<template v-if="!showAdvancedView">
|
||||||
|
<div class="flex-grow pt-2 mb-2">
|
||||||
|
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex-grow pt-2 mb-2">
|
||||||
|
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full relative mb-1">
|
||||||
|
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRSSFeedCustomOwnerName" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full relative mb-1">
|
||||||
|
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRSSFeedCustomOwnerEmail" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {
|
||||||
|
return {
|
||||||
|
preventIndexing: true,
|
||||||
|
ownerName: '',
|
||||||
|
ownerEmail: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showAdvancedView: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {},
|
||||||
|
computed: {
|
||||||
|
preventIndexing: {
|
||||||
|
get() {
|
||||||
|
return this.value.preventIndexing
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit('input', {
|
||||||
|
...this.value,
|
||||||
|
preventIndexing: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ownerName: {
|
||||||
|
get() {
|
||||||
|
return this.value.ownerName
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit('input', {
|
||||||
|
...this.value,
|
||||||
|
ownerName: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ownerEmail: {
|
||||||
|
get() {
|
||||||
|
return this.value.ownerEmail
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit('input', {
|
||||||
|
...this.value,
|
||||||
|
ownerEmail: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<modals-fullscreen-modal v-model="show" :processing="processing">
|
||||||
|
<div class="flex items-end justify-end h-16 pr-4 pb-2 mb-2">
|
||||||
|
<!-- <h1 class="text-lg">RSS Feed</h1> -->
|
||||||
|
<button class="flex" @click="show = false">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full px-2 h-[calc(100%-138px)] overflow-y-auto">
|
||||||
|
<div v-if="currentFeed" class="w-full">
|
||||||
|
<div class="w-full relative">
|
||||||
|
<h1 class="text-lg mb-4">RSS feed is open</h1>
|
||||||
|
|
||||||
|
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
|
||||||
|
|
||||||
|
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-gray-300'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : 'content_copy' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentFeed.meta" class="mt-5">
|
||||||
|
<div class="flex py-0.5">
|
||||||
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
|
||||||
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ currentFeed.meta.ownerName }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
|
||||||
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ currentFeed.meta.ownerEmail }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<div class="w-full relative mb-2">
|
||||||
|
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
|
||||||
|
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
|
||||||
|
</div>
|
||||||
|
<modals-rssfeeds-rss-feed-metadata-builder v-model="metadataDetails" />
|
||||||
|
|
||||||
|
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>
|
||||||
|
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="userIsAdminOrUp" class="flex items-center px-2 h-16">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="currentFeed" color="error" class="w-full" @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
|
||||||
|
<ui-btn v-else color="success" class="w-full" @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</modals-fullscreen-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newFeedSlug: null,
|
||||||
|
currentFeed: null,
|
||||||
|
metadataDetails: {
|
||||||
|
preventIndexing: true,
|
||||||
|
ownerName: '',
|
||||||
|
ownerEmail: ''
|
||||||
|
},
|
||||||
|
linkCopied: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.linkCopied = false
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showRSSFeedOpenCloseModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serverAddress() {
|
||||||
|
return this.$store.getters['user/getServerAddress']
|
||||||
|
},
|
||||||
|
rssFeedEntity() {
|
||||||
|
return this.$store.state.globals.rssFeedEntity || {}
|
||||||
|
},
|
||||||
|
entityId() {
|
||||||
|
return this.rssFeedEntity.id
|
||||||
|
},
|
||||||
|
entityType() {
|
||||||
|
return this.rssFeedEntity.type
|
||||||
|
},
|
||||||
|
entityFeed() {
|
||||||
|
return this.rssFeedEntity.feed
|
||||||
|
},
|
||||||
|
hasEpisodesWithoutPubDate() {
|
||||||
|
return !!this.rssFeedEntity.hasEpisodesWithoutPubDate
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.rssFeedEntity.name
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
demoFeedUrl() {
|
||||||
|
return `${this.serverAddress}/feed/${this.newFeedSlug}`
|
||||||
|
},
|
||||||
|
isHttp() {
|
||||||
|
return !!this.serverAddress?.startsWith('http://')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openFeed() {
|
||||||
|
if (!this.newFeedSlug) {
|
||||||
|
this.$toast.error('Must set a feed slug')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
|
||||||
|
if (this.newFeedSlug !== sanitized) {
|
||||||
|
this.newFeedSlug = sanitized
|
||||||
|
this.$toast.warning('Slug had to be modified - Run again')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
serverAddress: this.serverAddress,
|
||||||
|
slug: this.newFeedSlug,
|
||||||
|
metadataDetails: this.metadataDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Payload', payload)
|
||||||
|
this.$nativeHttp
|
||||||
|
.post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
console.log('Opened RSS Feed', data)
|
||||||
|
this.currentFeed = data.feed
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to open RSS Feed', error)
|
||||||
|
const errorMsg = error.response ? error.response.data : null
|
||||||
|
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async copyToClipboard(str) {
|
||||||
|
await this.$copyToClipboard(str)
|
||||||
|
this.linkCopied = true
|
||||||
|
},
|
||||||
|
closeFeed() {
|
||||||
|
this.processing = true
|
||||||
|
this.$nativeHttp
|
||||||
|
.post(`/api/feeds/${this.currentFeed.id}/close`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to close RSS feed', error)
|
||||||
|
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (!this.entityId) return
|
||||||
|
this.newFeedSlug = this.entityId
|
||||||
|
this.currentFeed = this.entityFeed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,28 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="comic-reader" class="w-full h-full relative">
|
<div id="comic-reader" class="w-full h-full relative">
|
||||||
<div v-show="showPageMenu" v-click-outside="clickOutsideObj" class="pagemenu absolute top-12 right-16 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
<modals-modal v-model="showInfoMenu" height="90%">
|
||||||
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click.stop="setPage(index)">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click.stop="showInfoMenu = false">
|
||||||
<p class="text-sm truncate">{{ file }}</p>
|
<div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
|
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||||
|
<p class="text-xs">
|
||||||
|
<strong>{{ key }}</strong>
|
||||||
|
: {{ comicMetadata[key] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</modals-modal>
|
||||||
<div v-show="showInfoMenu" v-click-outside="clickedOutsideInfoMenu" class="pagemenu absolute top-12 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" @click.stop.prevent>
|
|
||||||
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
|
||||||
<p class="text-xs">
|
|
||||||
<strong>{{ key }}</strong>
|
|
||||||
: {{ comicMetadata[key] }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="comicMetadata" class="absolute top-0 right-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
|
|
||||||
<span class="material-icons text-lg">more</span>
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @mouseup.prevent @click.stop.prevent="clickShowPageMenu">
|
|
||||||
<span class="material-icons text-lg">menu</span>
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-0 left-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20" @click.stop>
|
|
||||||
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden m-auto comicwrapper relative">
|
<div class="overflow-hidden m-auto comicwrapper relative">
|
||||||
<div class="h-full flex justify-center">
|
<div class="h-full flex justify-center">
|
||||||
@@ -33,6 +22,13 @@
|
|||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-xs">{{ page }} / {{ numPages }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-dialog v-model="showPageMenu" :items="pageItems" :selected="page" :width="360" item-padding-y="8px" @action="setPage" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,7 +43,13 @@ Archive.init({
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String
|
url: String,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
isLocal: Boolean,
|
||||||
|
keepProgress: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -62,11 +64,7 @@ export default {
|
|||||||
loadTimeout: null,
|
loadTimeout: null,
|
||||||
loadedFirstPage: false,
|
loadedFirstPage: false,
|
||||||
comicMetadata: null,
|
comicMetadata: null,
|
||||||
clickOutsideObj: {
|
pageMenuWidth: 256
|
||||||
handler: this.clickedOutside,
|
|
||||||
events: ['mousedown'],
|
|
||||||
isActive: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -78,31 +76,127 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem?.id
|
||||||
|
},
|
||||||
|
localLibraryItem() {
|
||||||
|
if (this.isLocal) return this.libraryItem
|
||||||
|
return this.libraryItem.localLibraryItem || null
|
||||||
|
},
|
||||||
|
localLibraryItemId() {
|
||||||
|
return this.localLibraryItem?.id
|
||||||
|
},
|
||||||
|
serverLibraryItemId() {
|
||||||
|
if (!this.isLocal) return this.libraryItem.id
|
||||||
|
// Check if local library item is connected to the current server
|
||||||
|
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
|
||||||
|
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
|
||||||
|
return this.libraryItem.libraryItemId
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
comicMetadataKeys() {
|
comicMetadataKeys() {
|
||||||
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
||||||
},
|
},
|
||||||
canGoNext() {
|
canGoNext() {
|
||||||
return this.page < this.numPages - 1
|
return this.page < this.numPages
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.page > 0
|
return this.page > 1
|
||||||
|
},
|
||||||
|
userItemProgress() {
|
||||||
|
if (this.isLocal) return this.localItemProgress
|
||||||
|
return this.serverItemProgress
|
||||||
|
},
|
||||||
|
localItemProgress() {
|
||||||
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||||
|
},
|
||||||
|
serverItemProgress() {
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||||
|
},
|
||||||
|
savedPage() {
|
||||||
|
if (!this.keepProgress) return 0
|
||||||
|
|
||||||
|
// Validate ebookLocation is a number
|
||||||
|
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
|
||||||
|
return Number(this.userItemProgress.ebookLocation)
|
||||||
|
},
|
||||||
|
selectedCleanedPage() {
|
||||||
|
return this.cleanedPageNames[this.page - 1]
|
||||||
|
},
|
||||||
|
cleanedPageNames() {
|
||||||
|
return (
|
||||||
|
this.pages?.map((p) => {
|
||||||
|
if (p.length > 40) {
|
||||||
|
let firstHalf = p.slice(0, 18)
|
||||||
|
let lastHalf = p.slice(p.length - 17)
|
||||||
|
return `${firstHalf} ... ${lastHalf}`
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pageItems() {
|
||||||
|
let index = 1
|
||||||
|
return this.cleanedPageNames.map((p) => {
|
||||||
|
return {
|
||||||
|
text: p,
|
||||||
|
value: index++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
isPlayerOpen() {
|
||||||
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async updateProgress() {
|
||||||
|
if (!this.keepProgress) return
|
||||||
|
|
||||||
|
if (!this.numPages) {
|
||||||
|
console.error('Num pages not loaded')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.savedPage === this.page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ebookLocation: String(this.page),
|
||||||
|
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local item
|
||||||
|
if (this.localLibraryItemId) {
|
||||||
|
const localPayload = {
|
||||||
|
localLibraryItemId: this.localLibraryItemId,
|
||||||
|
...payload
|
||||||
|
}
|
||||||
|
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
|
||||||
|
if (localResponse.localMediaProgress) {
|
||||||
|
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update server item
|
||||||
|
if (this.serverLibraryItemId) {
|
||||||
|
this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||||
|
console.error('ComicReader.updateProgress failed:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
clickShowInfoMenu() {
|
clickShowInfoMenu() {
|
||||||
this.showInfoMenu = !this.showInfoMenu
|
this.showInfoMenu = !this.showInfoMenu
|
||||||
this.showPageMenu = false
|
this.showPageMenu = false
|
||||||
},
|
},
|
||||||
clickShowPageMenu() {
|
clickShowPageMenu() {
|
||||||
|
if (!this.numPages) return
|
||||||
this.showPageMenu = !this.showPageMenu
|
this.showPageMenu = !this.showPageMenu
|
||||||
this.showInfoMenu = false
|
this.showInfoMenu = false
|
||||||
},
|
},
|
||||||
clickedOutside() {
|
|
||||||
this.showPageMenu = false
|
|
||||||
},
|
|
||||||
clickedOutsideInfoMenu() {
|
|
||||||
this.showInfoMenu = false
|
|
||||||
},
|
|
||||||
next() {
|
next() {
|
||||||
if (!this.canGoNext) return
|
if (!this.canGoNext) return
|
||||||
this.setPage(this.page + 1)
|
this.setPage(this.page + 1)
|
||||||
@@ -111,13 +205,15 @@ export default {
|
|||||||
if (!this.canGoPrev) return
|
if (!this.canGoPrev) return
|
||||||
this.setPage(this.page - 1)
|
this.setPage(this.page - 1)
|
||||||
},
|
},
|
||||||
setPage(index) {
|
setPage(page) {
|
||||||
if (index < 0 || index > this.numPages - 1) {
|
if (page <= 0 || page > this.numPages) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.showPageMenu = false
|
this.showPageMenu = false
|
||||||
const filename = this.pages[index]
|
const filename = this.pages[page - 1]
|
||||||
this.page = index
|
this.page = page
|
||||||
|
|
||||||
|
this.updateProgress()
|
||||||
return this.extractFile(filename)
|
return this.extractFile(filename)
|
||||||
},
|
},
|
||||||
setLoadTimeout() {
|
setLoadTimeout() {
|
||||||
@@ -147,11 +243,14 @@ export default {
|
|||||||
},
|
},
|
||||||
async extract() {
|
async extract() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
console.log('Extracting', this.url)
|
|
||||||
|
|
||||||
var buff = await this.$axios.$get(this.url, {
|
const buff = await this.$axios.$get(this.url, {
|
||||||
responseType: 'blob'
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const archive = await Archive.open(buff)
|
const archive = await Archive.open(buff)
|
||||||
const originalFilesObject = await archive.getFilesObject()
|
const originalFilesObject = await archive.getFilesObject()
|
||||||
// to support images in subfolders we need to flatten the object
|
// to support images in subfolders we need to flatten the object
|
||||||
@@ -166,10 +265,33 @@ export default {
|
|||||||
|
|
||||||
this.numPages = this.pages.length
|
this.numPages = this.pages.length
|
||||||
|
|
||||||
|
// Calculate page menu size
|
||||||
|
const largestFilename = this.cleanedPageNames
|
||||||
|
.map((p) => p)
|
||||||
|
.sort((a, b) => a.length - b.length)
|
||||||
|
.pop()
|
||||||
|
const pEl = document.createElement('p')
|
||||||
|
pEl.innerText = largestFilename
|
||||||
|
pEl.style.fontSize = '0.875rem'
|
||||||
|
pEl.style.opacity = 0
|
||||||
|
pEl.style.position = 'absolute'
|
||||||
|
document.body.appendChild(pEl)
|
||||||
|
const textWidth = pEl.getBoundingClientRect()?.width
|
||||||
|
if (textWidth) {
|
||||||
|
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
|
||||||
|
}
|
||||||
|
pEl.remove()
|
||||||
|
|
||||||
if (this.pages.length) {
|
if (this.pages.length) {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
await this.setPage(0)
|
|
||||||
|
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
|
||||||
|
await this.setPage(startPage)
|
||||||
this.loadedFirstPage = true
|
this.loadedFirstPage = true
|
||||||
|
|
||||||
|
this.$emit('loaded', {
|
||||||
|
hasMetadata: this.comicMetadata
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
this.$toast.error('Unable to extract pages')
|
this.$toast.error('Unable to extract pages')
|
||||||
this.loading = false
|
this.loading = false
|
||||||
@@ -196,7 +318,6 @@ export default {
|
|||||||
return flattenObject(filesObject)
|
return flattenObject(filesObject)
|
||||||
},
|
},
|
||||||
async extractXmlFile(filename) {
|
async extractXmlFile(filename) {
|
||||||
console.log('extracting xml filename', filename)
|
|
||||||
try {
|
try {
|
||||||
var file = await this.filesObject[filename].extract()
|
var file = await this.filesObject[filename].extract()
|
||||||
var reader = new FileReader()
|
var reader = new FileReader()
|
||||||
@@ -214,8 +335,8 @@ export default {
|
|||||||
},
|
},
|
||||||
parseImageFilename(filename) {
|
parseImageFilename(filename) {
|
||||||
var basename = Path.basename(filename, Path.extname(filename))
|
var basename = Path.basename(filename, Path.extname(filename))
|
||||||
var numbersinpath = basename.match(/\d{1,5}/g)
|
var numbersinpath = basename.match(/\d+/g)
|
||||||
if (!numbersinpath || !numbersinpath.length) {
|
if (!numbersinpath?.length) {
|
||||||
return {
|
return {
|
||||||
index: -1,
|
index: -1,
|
||||||
filename
|
filename
|
||||||
@@ -261,9 +382,6 @@ export default {
|
|||||||
padding-top: 36px;
|
padding-top: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagemenu {
|
|
||||||
max-height: calc(100% - 80px);
|
|
||||||
}
|
|
||||||
.comicimg {
|
.comicimg {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="epub-frame" class="w-full">
|
<div id="epub-frame" class="w-full">
|
||||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div>
|
<div id="viewer" class="h-full w-full"></div>
|
||||||
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
|
||||||
<p class="text-xs">epub</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<p class="text-sm">{{ progress }}%</p>
|
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-primary text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||||
|
<p v-if="totalLocations" class="text-xs text-slate-600">Location {{ currentLocationNum }} of {{ totalLocations }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-xs">{{ progress }}%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -15,34 +15,126 @@ import ePub from 'epubjs'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String
|
url: String,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
isLocal: Boolean,
|
||||||
|
keepProgress: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
/** @type {ePub.Book} */
|
||||||
book: null,
|
book: null,
|
||||||
|
/** @type {ePub.Rendition} */
|
||||||
rendition: null,
|
rendition: null,
|
||||||
chapters: [],
|
|
||||||
title: '',
|
|
||||||
author: '',
|
|
||||||
progress: 0,
|
progress: 0,
|
||||||
hasNext: true,
|
totalLocations: 0,
|
||||||
hasPrev: false
|
currentLocationNum: 0,
|
||||||
|
currentLocationCfi: null,
|
||||||
|
inittingDisplay: true,
|
||||||
|
ereaderSettings: {
|
||||||
|
theme: 'dark',
|
||||||
|
fontScale: 100,
|
||||||
|
lineSpacing: 115
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
playerLibraryItemId() {
|
isPlayerOpen() {
|
||||||
this.updateHeight()
|
this.updateHeight()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
playerLibraryItemId() {
|
userToken() {
|
||||||
return this.$store.state.playerLibraryItemId
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
/** @returns {string} */
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem?.id
|
||||||
|
},
|
||||||
|
localLibraryItem() {
|
||||||
|
if (this.isLocal) return this.libraryItem
|
||||||
|
return this.libraryItem.localLibraryItem || null
|
||||||
|
},
|
||||||
|
localLibraryItemId() {
|
||||||
|
return this.localLibraryItem?.id
|
||||||
|
},
|
||||||
|
serverLibraryItemId() {
|
||||||
|
if (!this.isLocal) return this.libraryItem.id
|
||||||
|
// Check if local library item is connected to the current server
|
||||||
|
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
|
||||||
|
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
|
||||||
|
return this.libraryItem.libraryItemId
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
isPlayerOpen() {
|
||||||
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
},
|
},
|
||||||
readerHeightOffset() {
|
readerHeightOffset() {
|
||||||
return this.playerLibraryItemId ? 196 : 96
|
return this.isPlayerOpen ? 204 : 104
|
||||||
|
},
|
||||||
|
/** @returns {Array<ePub.NavItem>} */
|
||||||
|
chapters() {
|
||||||
|
return this.book?.navigation?.toc || []
|
||||||
|
},
|
||||||
|
userItemProgress() {
|
||||||
|
if (this.isLocal) return this.localItemProgress
|
||||||
|
return this.serverItemProgress
|
||||||
|
},
|
||||||
|
localItemProgress() {
|
||||||
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||||
|
},
|
||||||
|
serverItemProgress() {
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||||
|
},
|
||||||
|
localStorageLocationsKey() {
|
||||||
|
return `ebookLocations-${this.libraryItemId}`
|
||||||
|
},
|
||||||
|
savedEbookLocation() {
|
||||||
|
if (!this.keepProgress) return null
|
||||||
|
if (!this.userItemProgress?.ebookLocation) return null
|
||||||
|
// Validate ebookLocation is an epubcfi
|
||||||
|
if (!String(this.userItemProgress.ebookLocation).startsWith('epubcfi')) return null
|
||||||
|
return this.userItemProgress.ebookLocation
|
||||||
|
},
|
||||||
|
isLightTheme() {
|
||||||
|
return this.ereaderSettings.theme === 'light'
|
||||||
|
},
|
||||||
|
themeRules() {
|
||||||
|
const isDark = this.ereaderSettings.theme === 'dark'
|
||||||
|
const fontColor = isDark ? '#fff' : '#000'
|
||||||
|
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'*': {
|
||||||
|
color: `${fontColor}!important`,
|
||||||
|
'background-color': `${backgroundColor}!important`,
|
||||||
|
'line-height': this.ereaderSettings.lineSpacing + '%!important'
|
||||||
|
},
|
||||||
|
a: {
|
||||||
|
color: `${fontColor}!important`
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateSettings(settings) {
|
||||||
|
this.ereaderSettings = settings
|
||||||
|
|
||||||
|
if (!this.rendition) return
|
||||||
|
|
||||||
|
this.applyTheme()
|
||||||
|
|
||||||
|
const fontScale = settings.fontScale || 100
|
||||||
|
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||||
|
this.rendition.spread(settings.spread || 'auto')
|
||||||
|
},
|
||||||
|
goToChapter(href) {
|
||||||
|
return this.rendition?.display(href)
|
||||||
|
},
|
||||||
updateHeight() {
|
updateHeight() {
|
||||||
if (this.rendition && this.rendition.resize) {
|
if (this.rendition && this.rendition.resize) {
|
||||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||||
@@ -58,71 +150,243 @@ export default {
|
|||||||
this.rendition.next()
|
this.rendition.next()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
initEpub() {
|
/**
|
||||||
var book = ePub(this.url)
|
* @param {object} payload
|
||||||
this.book = book
|
* @param {string} payload.ebookLocation - CFI of the current location
|
||||||
|
* @param {string} payload.ebookProgress - eBook Progress Percentage
|
||||||
|
*/
|
||||||
|
async updateProgress(payload) {
|
||||||
|
if (!this.keepProgress) return
|
||||||
|
|
||||||
this.rendition = book.renderTo('viewer', {
|
// Update local item
|
||||||
|
if (this.localLibraryItemId) {
|
||||||
|
const localPayload = {
|
||||||
|
localLibraryItemId: this.localLibraryItemId,
|
||||||
|
...payload
|
||||||
|
}
|
||||||
|
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
|
||||||
|
if (localResponse.localMediaProgress) {
|
||||||
|
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update server item
|
||||||
|
if (this.serverLibraryItemId) {
|
||||||
|
this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||||
|
console.error('EpubReader.updateProgress failed:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getAllEbookLocationData() {
|
||||||
|
const locations = []
|
||||||
|
let totalSize = 0 // Total in bytes
|
||||||
|
|
||||||
|
for (const key in localStorage) {
|
||||||
|
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ebookLocations = JSON.parse(localStorage[key])
|
||||||
|
if (!ebookLocations.locations) throw new Error('Invalid locations object')
|
||||||
|
|
||||||
|
ebookLocations.key = key
|
||||||
|
ebookLocations.size = (localStorage[key].length + key.length) * 2
|
||||||
|
locations.push(ebookLocations)
|
||||||
|
totalSize += ebookLocations.size
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse ebook locations', key, error)
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by oldest lastAccessed first
|
||||||
|
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
locations,
|
||||||
|
totalSize
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** @param {string} locationString */
|
||||||
|
checkSaveLocations(locationString) {
|
||||||
|
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
|
||||||
|
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
|
||||||
|
|
||||||
|
// Too large overall
|
||||||
|
if (newLocationsSize > maxSizeInBytes) {
|
||||||
|
console.error('Epub locations are too large to store. Size =', newLocationsSize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ebookLocationsData = this.getAllEbookLocationData()
|
||||||
|
|
||||||
|
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
|
||||||
|
|
||||||
|
// Remove epub locations until there is room for locations
|
||||||
|
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
|
||||||
|
const oldestLocation = ebookLocationsData.locations.shift()
|
||||||
|
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
|
||||||
|
availableSpace += oldestLocation.size
|
||||||
|
localStorage.removeItem(oldestLocation.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
|
||||||
|
this.saveLocations(locationString)
|
||||||
|
},
|
||||||
|
/** @param {string} locationString */
|
||||||
|
saveLocations(locationString) {
|
||||||
|
localStorage.setItem(
|
||||||
|
this.localStorageLocationsKey,
|
||||||
|
JSON.stringify({
|
||||||
|
lastAccessed: Date.now(),
|
||||||
|
locations: locationString
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
loadLocations() {
|
||||||
|
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
|
||||||
|
if (!locationsObjString) return null
|
||||||
|
|
||||||
|
const locationsObject = JSON.parse(locationsObjString)
|
||||||
|
|
||||||
|
// Remove invalid location objects
|
||||||
|
if (!locationsObject.locations) {
|
||||||
|
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
|
||||||
|
localStorage.removeItem(this.localStorageLocationsKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastAccessed
|
||||||
|
this.saveLocations(locationsObject.locations)
|
||||||
|
|
||||||
|
return locationsObject.locations
|
||||||
|
},
|
||||||
|
/** @param {string} location - CFI of the new location */
|
||||||
|
relocated(location) {
|
||||||
|
console.log(`[EpubReader] relocated ${location.start.cfi}`)
|
||||||
|
if (this.inittingDisplay) {
|
||||||
|
console.log(`[EpubReader] relocated but initting display ${location.start.cfi}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.currentLocationNum = location.start.location
|
||||||
|
|
||||||
|
if (this.currentLocationCfi === location.start.cfi) {
|
||||||
|
console.log(`[EpubReader] location already saved`, location.start.cfi)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[EpubReader] Saving new location ${location.start.cfi}`)
|
||||||
|
this.currentLocationCfi = location.start.cfi
|
||||||
|
|
||||||
|
if (location.end.percentage) {
|
||||||
|
this.updateProgress({
|
||||||
|
ebookLocation: location.start.cfi,
|
||||||
|
ebookProgress: location.end.percentage
|
||||||
|
})
|
||||||
|
this.progress = Math.round(location.end.percentage * 100)
|
||||||
|
} else {
|
||||||
|
this.updateProgress({
|
||||||
|
ebookLocation: location.start.cfi
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initEpub() {
|
||||||
|
this.progress = Math.round((this.userItemProgress?.ebookProgress || 0) * 100)
|
||||||
|
|
||||||
|
/** @type {EpubReader} */
|
||||||
|
const reader = this
|
||||||
|
console.log('[EpubReader] initEpub', reader.url)
|
||||||
|
/** @type {ePub.Book} */
|
||||||
|
reader.book = new ePub(reader.url, {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight - this.readerHeightOffset,
|
||||||
|
openAs: 'epub',
|
||||||
|
requestHeaders: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {ePub.Rendition} */
|
||||||
|
reader.rendition = reader.book.renderTo('viewer', {
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight - this.readerHeightOffset,
|
height: window.innerHeight - this.readerHeightOffset,
|
||||||
snap: true,
|
snap: true,
|
||||||
manager: 'continuous',
|
manager: 'continuous',
|
||||||
flow: 'paginated'
|
flow: 'paginated'
|
||||||
})
|
})
|
||||||
const displayed = this.rendition.display()
|
|
||||||
|
|
||||||
book.ready
|
reader.book.ready.then(() => {
|
||||||
.then(() => {
|
console.log('%c [EpubReader] Book ready', 'color:cyan;')
|
||||||
console.log('Book ready')
|
|
||||||
return book.locations.generate(1600)
|
let displayCfi = reader.book.locations.start
|
||||||
|
if (this.savedEbookLocation && reader.book.spine.get(this.savedEbookLocation)) {
|
||||||
|
displayCfi = this.savedEbookLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.rendition.on('displayed', async () => {
|
||||||
|
console.log('%c [EpubReader] Rendition displayed', 'color:blue;')
|
||||||
|
|
||||||
|
// Overriding the needsSnap function in epubjs `snap.js` to fix a bug with scrollLeft being a decimal
|
||||||
|
reader.rendition.manager.snapper.needsSnap = function () {
|
||||||
|
let left = Math.round(this.scrollLeft)
|
||||||
|
let snapWidth = this.layout.pageWidth * this.layout.divisor
|
||||||
|
return left % snapWidth !== 0
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then((locations) => {
|
|
||||||
// console.log('Loaded locations', locations)
|
reader.rendition.on('rendered', (section, view) => {
|
||||||
// Wait for book to be rendered to get current page
|
this.applyTheme()
|
||||||
displayed.then(() => {
|
console.log('%c [EpubReader] Rendition rendered', 'color:red;', section, view)
|
||||||
// Get the current CFI
|
})
|
||||||
var currentLocation = this.rendition.currentLocation()
|
|
||||||
if (!currentLocation.start) {
|
// set up event listeners
|
||||||
console.error('No Start', currentLocation)
|
reader.rendition.on('relocated', reader.relocated)
|
||||||
} else {
|
|
||||||
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
reader.rendition.on('displayError', (err) => {
|
||||||
console.log('current page', currentPage)
|
console.log('[EpubReader] Display error', err)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
reader.rendition.on('touchstart', (event) => {
|
||||||
|
this.$emit('touchstart', event)
|
||||||
|
})
|
||||||
|
reader.rendition.on('touchend', (event) => {
|
||||||
|
this.$emit('touchend', event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// load ebook cfi locations
|
||||||
|
const savedLocations = this.loadLocations()
|
||||||
|
if (savedLocations) {
|
||||||
|
reader.book.locations.load(savedLocations)
|
||||||
|
this.totalLocations = reader.book.locations.length()
|
||||||
|
} else {
|
||||||
|
reader.book.locations.generate(100).then(() => {
|
||||||
|
this.totalLocations = reader.book.locations.length()
|
||||||
|
this.currentLocationNum = reader.rendition.currentLocation()?.start.location || 0
|
||||||
|
this.checkSaveLocations(reader.book.locations.save())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: To get the correct page need to render twice. On book ready and after first display. Figure out why
|
||||||
|
console.log(`[EpubReader] Displaying cfi ${displayCfi}`)
|
||||||
|
this.currentLocationCfi = displayCfi
|
||||||
|
reader.rendition.display(displayCfi).then(() => {
|
||||||
|
reader.rendition.display(displayCfi).then(() => {
|
||||||
|
this.inittingDisplay = false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
book.loaded.navigation.then((toc) => {
|
|
||||||
var _chapters = []
|
|
||||||
toc.forEach((chapter) => {
|
|
||||||
_chapters.push(chapter)
|
|
||||||
})
|
|
||||||
this.chapters = _chapters
|
|
||||||
})
|
})
|
||||||
// book.loaded.metadata.then((metadata) => {
|
},
|
||||||
// this.author = metadata.creator
|
applyTheme() {
|
||||||
// this.title = metadata.title
|
if (!this.rendition) return
|
||||||
// })
|
this.rendition.getContents().forEach((c) => {
|
||||||
|
c.addStylesheetRules(this.themeRules)
|
||||||
// const spine_get = book.spine.get.bind(book.spine)
|
|
||||||
// book.spine.get = function (target) {
|
|
||||||
// var t = spine_get(target)
|
|
||||||
// console.log(t, target)
|
|
||||||
// // while (t == null && target.includes('#')) {
|
|
||||||
// // target = target.split('#')[0]
|
|
||||||
// // t = spine_get(target)
|
|
||||||
// // }
|
|
||||||
// return t
|
|
||||||
// }
|
|
||||||
|
|
||||||
this.rendition.on('relocated', (location) => {
|
|
||||||
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
|
||||||
this.progress = Math.floor(percent * 100)
|
|
||||||
|
|
||||||
this.hasNext = !location.atEnd
|
|
||||||
this.hasPrev = !location.atStart
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.book?.destroy()
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initEpub()
|
this.initEpub()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mobi-ebook-viewer w-full relative">
|
<div class="mobi-ebook-viewer w-full relative">
|
||||||
<div class="absolute overflow-hidden left-0 right-0 w-full max-w-full m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
<div class="absolute overflow-hidden left-0 top-0 w-screen max-w-screen m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||||
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
|
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
|
||||||
</div>
|
</div>
|
||||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
|
||||||
<p class="text-xs">mobi</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -17,14 +13,18 @@ import defaultCss from '@/assets/ebooks/basic.js'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String
|
url: String,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
playerLibraryItemId() {
|
userToken() {
|
||||||
return this.$store.state.playerLibraryItemId
|
return this.$store.getters['user/getToken']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -85,7 +85,10 @@ export default {
|
|||||||
async initMobi() {
|
async initMobi() {
|
||||||
// Fetch mobi file as blob
|
// Fetch mobi file as blob
|
||||||
var buff = await this.$axios.$get(this.url, {
|
var buff = await this.$axios.$get(this.url, {
|
||||||
responseType: 'blob'
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
})
|
})
|
||||||
var reader = new FileReader()
|
var reader = new FileReader()
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
@@ -119,14 +122,14 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.mobi-ebook-viewer {
|
.mobi-ebook-viewer {
|
||||||
height: calc(100% - 52px);
|
height: calc(100% - 32px);
|
||||||
max-height: calc(100% - 52px);
|
max-height: calc(100% - 32px);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.reader-player-open .mobi-ebook-viewer {
|
.reader-player-open .mobi-ebook-viewer {
|
||||||
height: calc(100% - 152px);
|
height: calc(100% - 132px);
|
||||||
max-height: calc(100% - 152px);
|
max-height: calc(100% - 132px);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full pt-6 relative">
|
<div class="w-full h-full relative">
|
||||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||||
<div class="flex items-center justify-center h-full w-1/2">
|
<div class="flex items-center justify-center h-full w-1/2">
|
||||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||||
@@ -11,69 +11,192 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-0 right-1 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-6 flex items-center text-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<p class="font-mono text-xs">{{ page }} / {{ numPages }}</p>
|
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||||
|
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||||
|
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex-grow" />
|
||||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
<p class="text-xs">{{ page }} / {{ numPages }}</p>
|
||||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
|
||||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import pdf from 'vue-pdf'
|
import pdf from '@teckel/vue-pdf'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
pdf
|
pdf
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
url: String
|
url: String,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
isLocal: Boolean,
|
||||||
|
keepProgress: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
loadedRatio: 0,
|
loadedRatio: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
numPages: 0
|
numPages: 0,
|
||||||
|
windowWidth: 0,
|
||||||
|
windowHeight: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
localLibraryItem() {
|
||||||
|
if (this.isLocal) return this.libraryItem
|
||||||
|
return this.libraryItem.localLibraryItem || null
|
||||||
|
},
|
||||||
|
localLibraryItemId() {
|
||||||
|
return this.localLibraryItem?.id
|
||||||
|
},
|
||||||
|
serverLibraryItemId() {
|
||||||
|
if (!this.isLocal) return this.libraryItem.id
|
||||||
|
// Check if local library item is connected to the current server
|
||||||
|
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
|
||||||
|
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
|
||||||
|
return this.libraryItem.libraryItemId
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
pdfWidth() {
|
pdfWidth() {
|
||||||
return this.pdfHeight * 0.6667
|
if (this.windowWidth > this.windowHeight) {
|
||||||
|
// Landscape
|
||||||
|
return this.windowHeight * 0.6
|
||||||
|
} else {
|
||||||
|
// Portrait
|
||||||
|
return this.windowWidth
|
||||||
|
}
|
||||||
},
|
},
|
||||||
pdfHeight() {
|
pdfHeight() {
|
||||||
return window.innerHeight - 120
|
return this.pdfWidth * 1.667
|
||||||
},
|
},
|
||||||
canGoNext() {
|
canGoNext() {
|
||||||
return this.page < this.numPages
|
return this.page < this.numPages
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.page > 1
|
return this.page > 1
|
||||||
|
},
|
||||||
|
userItemProgress() {
|
||||||
|
if (this.isLocal) return this.localItemProgress
|
||||||
|
return this.serverItemProgress
|
||||||
|
},
|
||||||
|
localItemProgress() {
|
||||||
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||||
|
},
|
||||||
|
serverItemProgress() {
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||||
|
},
|
||||||
|
savedPage() {
|
||||||
|
if (!this.keepProgress) return 0
|
||||||
|
|
||||||
|
// Validate ebookLocation is a number
|
||||||
|
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
|
||||||
|
return Number(this.userItemProgress.ebookLocation)
|
||||||
|
},
|
||||||
|
pdfDocInitParams() {
|
||||||
|
return {
|
||||||
|
url: this.url,
|
||||||
|
httpHeaders: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isPlayerOpen() {
|
||||||
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async updateProgress() {
|
||||||
|
if (!this.keepProgress) return
|
||||||
|
|
||||||
|
if (!this.numPages) {
|
||||||
|
console.error('Num pages not loaded')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ebookLocation: String(this.page),
|
||||||
|
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local item
|
||||||
|
if (this.localLibraryItemId) {
|
||||||
|
const localPayload = {
|
||||||
|
localLibraryItemId: this.localLibraryItemId,
|
||||||
|
...payload
|
||||||
|
}
|
||||||
|
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
|
||||||
|
if (localResponse.localMediaProgress) {
|
||||||
|
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update server item
|
||||||
|
if (this.serverLibraryItemId) {
|
||||||
|
this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||||
|
console.error('PdfReader.updateProgress failed:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadedEvt() {
|
||||||
|
if (this.savedPage && this.savedPage > 0 && this.savedPage <= this.numPages) {
|
||||||
|
this.page = this.savedPage
|
||||||
|
}
|
||||||
|
},
|
||||||
numPagesLoaded(e) {
|
numPagesLoaded(e) {
|
||||||
this.numPages = e
|
this.numPages = e
|
||||||
},
|
},
|
||||||
prev() {
|
prev() {
|
||||||
if (this.page <= 1) return
|
if (this.page <= 1) return
|
||||||
this.page--
|
this.page--
|
||||||
|
this.updateProgress()
|
||||||
},
|
},
|
||||||
next() {
|
next() {
|
||||||
if (this.page >= this.numPages) return
|
if (this.page >= this.numPages) return
|
||||||
this.page++
|
this.page++
|
||||||
|
this.updateProgress()
|
||||||
},
|
},
|
||||||
error(err) {
|
error(err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
},
|
||||||
|
screenOrientationChange() {
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
this.windowHeight = window.innerHeight
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
this.windowHeight = window.innerHeight
|
||||||
|
|
||||||
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
|
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', this.screenOrientationChange)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
|
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', this.screenOrientationChange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
+241
-38
@@ -1,15 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-16" :class="{ 'reader-player-open': !!playerLibraryItemId }">
|
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||||
<div class="h-16 pt-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
|
<!-- toolbar -->
|
||||||
<p class="w-5/6 truncate">{{ title }}</p>
|
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-white" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||||
<div class="flex-grow" />
|
<div class="flex items-center mb-2">
|
||||||
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
|
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
||||||
|
<span class="material-icons-outlined text-3xl text-white">chevron_left</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<button v-if="isComic || isEpub" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn">
|
||||||
|
<span class="material-icons-outlined text-2xl text-white">format_list_bulleted</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn">
|
||||||
|
<span class="material-icons text-2xl text-white">settings</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
||||||
|
<span class="material-icons text-2xl text-white">more</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" />
|
|
||||||
|
<!-- ereader -->
|
||||||
|
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
|
||||||
|
|
||||||
|
<!-- table of contents modal -->
|
||||||
|
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
|
||||||
|
<div class="flex items-end justify-between h-20 px-4 pb-2">
|
||||||
|
<h1 class="text-lg">Table of Contents</h1>
|
||||||
|
<button class="flex" @click.stop="showTOCModal = false">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- chapters list -->
|
||||||
|
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||||
|
<div class="w-full h-full px-4">
|
||||||
|
<ul>
|
||||||
|
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||||
|
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||||
|
<ul v-if="chapter.subitems.length">
|
||||||
|
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
||||||
|
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="!chapters.length" class="flex h-full items-center justify-center">
|
||||||
|
<p class="text-xl">{{ 'No Chapters' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-fullscreen-modal>
|
||||||
|
|
||||||
|
<!-- ereader settings modal -->
|
||||||
|
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
|
||||||
|
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
|
||||||
|
<h1 class="text-lg">Ereader Settings</h1>
|
||||||
|
<button class="flex" @click="showSettingsModal = false">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||||
|
<div class="w-full h-full px-4">
|
||||||
|
<div class="flex items-center mb-8">
|
||||||
|
<div class="w-32">
|
||||||
|
<p class="text-base">Theme:</p>
|
||||||
|
</div>
|
||||||
|
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-8">
|
||||||
|
<div class="w-32">
|
||||||
|
<p class="text-base">Font scale:</p>
|
||||||
|
</div>
|
||||||
|
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-8">
|
||||||
|
<div class="w-32">
|
||||||
|
<p class="text-base">Line spacing:</p>
|
||||||
|
</div>
|
||||||
|
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-fullscreen-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -17,16 +97,32 @@ export default {
|
|||||||
touchstartY: 0,
|
touchstartY: 0,
|
||||||
touchendX: 0,
|
touchendX: 0,
|
||||||
touchendY: 0,
|
touchendY: 0,
|
||||||
touchstartTime: 0
|
touchstartTime: 0,
|
||||||
|
touchIdentifier: null,
|
||||||
|
showingToolbar: false,
|
||||||
|
showTOCModal: false,
|
||||||
|
showSettingsModal: false,
|
||||||
|
comicHasMetadata: false,
|
||||||
|
chapters: [],
|
||||||
|
ereaderSettings: {
|
||||||
|
theme: 'dark',
|
||||||
|
fontScale: 100,
|
||||||
|
lineSpacing: 115
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show: {
|
show: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
|
this.comicHasMetadata = false
|
||||||
this.registerListeners()
|
this.registerListeners()
|
||||||
|
this.hideToolbar()
|
||||||
|
|
||||||
|
console.log('showReader for ebookFile', JSON.stringify(this.ebookFile))
|
||||||
} else {
|
} else {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
|
this.$showHideStatusBar(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,10 +143,26 @@ export default {
|
|||||||
return this.$store.state.selectedLibraryItem
|
return this.$store.state.selectedLibraryItem
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.selectedLibraryItem ? this.selectedLibraryItem.media : null
|
return this.selectedLibraryItem?.media || null
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media ? this.media.metadata || {} : {}
|
return this.media?.metadata || {}
|
||||||
|
},
|
||||||
|
ereaderTheme() {
|
||||||
|
if (this.isEpub) return this.ereaderSettings.theme
|
||||||
|
return 'dark'
|
||||||
|
},
|
||||||
|
themeItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: 'Dark',
|
||||||
|
value: 'dark'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Light',
|
||||||
|
value: 'light'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
readerComponentName() {
|
readerComponentName() {
|
||||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
@@ -59,17 +171,19 @@ export default {
|
|||||||
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
folderId() {
|
|
||||||
return this.selectedLibraryItem ? this.selectedLibraryItem.folderId : null
|
|
||||||
},
|
|
||||||
libraryId() {
|
|
||||||
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
|
|
||||||
},
|
|
||||||
ebookFile() {
|
ebookFile() {
|
||||||
return this.media ? this.media.ebookFile : null
|
// ebook file id is passed when reading a supplementary ebook
|
||||||
|
if (this.ebookFileId) {
|
||||||
|
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
|
||||||
|
}
|
||||||
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
ebookFormat() {
|
ebookFormat() {
|
||||||
if (!this.ebookFile) return null
|
if (!this.ebookFile) return null
|
||||||
|
// Use file extension for supplementary ebook
|
||||||
|
if (!this.ebookFile.ebookFormat) {
|
||||||
|
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
|
||||||
|
}
|
||||||
return this.ebookFile.ebookFormat
|
return this.ebookFile.ebookFormat
|
||||||
},
|
},
|
||||||
ebookType() {
|
ebookType() {
|
||||||
@@ -91,27 +205,69 @@ export default {
|
|||||||
isComic() {
|
isComic() {
|
||||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
},
|
},
|
||||||
|
isLocal() {
|
||||||
|
return !!this.ebookFile?.isLocal || !!this.ebookFile?.localFileId
|
||||||
|
},
|
||||||
|
localContentUrl() {
|
||||||
|
return this.ebookFile?.contentUrl
|
||||||
|
},
|
||||||
ebookUrl() {
|
ebookUrl() {
|
||||||
if (!this.ebookFile) return null
|
if (!this.ebookFile) return null
|
||||||
let filepath = ''
|
if (this.localContentUrl) {
|
||||||
if (this.selectedLibraryItem.isFile) {
|
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||||
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
|
||||||
} else {
|
|
||||||
const itemRelPath = this.selectedLibraryItem.relPath
|
|
||||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
|
||||||
const relPath = this.ebookFile.metadata.relPath
|
|
||||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
|
||||||
|
|
||||||
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
|
||||||
}
|
}
|
||||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||||
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
|
|
||||||
|
if (this.ebookFileId) {
|
||||||
|
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
||||||
|
}
|
||||||
|
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
|
||||||
},
|
},
|
||||||
playerLibraryItemId() {
|
isPlayerOpen() {
|
||||||
return this.$store.state.playerLibraryItemId
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
|
},
|
||||||
|
keepProgress() {
|
||||||
|
return this.$store.state.ereaderKeepProgress
|
||||||
|
},
|
||||||
|
ebookFileId() {
|
||||||
|
return this.$store.state.ereaderFileId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
settingsUpdated() {
|
||||||
|
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
|
||||||
|
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
|
||||||
|
},
|
||||||
|
goToChapter(href) {
|
||||||
|
this.showTOCModal = false
|
||||||
|
this.$refs.readerComponent?.goToChapter(href)
|
||||||
|
},
|
||||||
|
readerMounted() {
|
||||||
|
if (this.isEpub) {
|
||||||
|
this.loadEreaderSettings()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readerLoaded(data) {
|
||||||
|
if (this.isComic) {
|
||||||
|
this.comicHasMetadata = data.hasMetadata
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickMetadataBtn() {
|
||||||
|
this.$refs.readerComponent?.clickShowInfoMenu()
|
||||||
|
},
|
||||||
|
clickTOCBtn() {
|
||||||
|
this.hideToolbar()
|
||||||
|
if (this.isComic) {
|
||||||
|
this.$refs.readerComponent?.clickShowPageMenu?.()
|
||||||
|
} else {
|
||||||
|
this.chapters = this.$refs.readerComponent?.chapters || []
|
||||||
|
this.showTOCModal = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickSettingsBtn() {
|
||||||
|
this.hideToolbar()
|
||||||
|
this.showSettingsModal = true
|
||||||
|
},
|
||||||
next() {
|
next() {
|
||||||
if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
|
if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
|
||||||
this.$refs.readerComponent.next()
|
this.$refs.readerComponent.next()
|
||||||
@@ -123,43 +279,90 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleGesture() {
|
handleGesture() {
|
||||||
// Touch must be less than 1s. Must be > 100px drag and X distance > Y distance
|
// Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
|
||||||
const touchTimeMs = Date.now() - this.touchstartTime
|
const touchTimeMs = Date.now() - this.touchstartTime
|
||||||
if (touchTimeMs >= 1000) {
|
if (touchTimeMs >= 1000) {
|
||||||
console.log('Touch too long', touchTimeMs)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
||||||
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
||||||
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
|
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
|
||||||
|
if (touchDistance < 30) {
|
||||||
|
if (this.showSettingsModal) {
|
||||||
|
this.showSettingsModal = false
|
||||||
|
} else {
|
||||||
|
this.toggleToolbar()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.touchendX < this.touchstartX) {
|
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
|
||||||
console.log('swiped left')
|
return
|
||||||
this.next()
|
|
||||||
}
|
}
|
||||||
if (this.touchendX > this.touchstartX) {
|
this.hideToolbar()
|
||||||
console.log('swiped right')
|
if (!this.isEpub) {
|
||||||
this.prev()
|
if (this.touchendX < this.touchstartX) {
|
||||||
|
this.next()
|
||||||
|
}
|
||||||
|
if (this.touchendX > this.touchstartX) {
|
||||||
|
this.prev()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showToolbar() {
|
||||||
|
this.showingToolbar = true
|
||||||
|
this.$showHideStatusBar(true)
|
||||||
|
},
|
||||||
|
hideToolbar() {
|
||||||
|
this.showingToolbar = false
|
||||||
|
this.$showHideStatusBar(false)
|
||||||
|
},
|
||||||
|
toggleToolbar() {
|
||||||
|
if (this.showingToolbar) this.hideToolbar()
|
||||||
|
else this.showToolbar()
|
||||||
|
},
|
||||||
touchstart(e) {
|
touchstart(e) {
|
||||||
|
// Ignore rapid touch
|
||||||
|
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.touchstartX = e.touches[0].screenX
|
this.touchstartX = e.touches[0].screenX
|
||||||
this.touchstartY = e.touches[0].screenY
|
this.touchstartY = e.touches[0].screenY
|
||||||
this.touchstartTime = Date.now()
|
this.touchstartTime = Date.now()
|
||||||
|
this.touchIdentifier = e.touches[0].identifier
|
||||||
},
|
},
|
||||||
touchend(e) {
|
touchend(e) {
|
||||||
|
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.touchendX = e.changedTouches[0].screenX
|
this.touchendX = e.changedTouches[0].screenX
|
||||||
this.touchendY = e.changedTouches[0].screenY
|
this.touchendY = e.changedTouches[0].screenY
|
||||||
this.handleGesture()
|
this.handleGesture()
|
||||||
},
|
},
|
||||||
|
closeEvt() {
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
loadEreaderSettings() {
|
||||||
|
try {
|
||||||
|
const settings = localStorage.getItem('ereaderSettings')
|
||||||
|
if (settings) {
|
||||||
|
this.ereaderSettings = JSON.parse(settings)
|
||||||
|
this.settingsUpdated()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load ereader settings', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
|
this.$eventBus.$on('close-ebook', this.closeEvt)
|
||||||
document.body.addEventListener('touchstart', this.touchstart)
|
document.body.addEventListener('touchstart', this.touchstart)
|
||||||
document.body.addEventListener('touchend', this.touchend)
|
document.body.addEventListener('touchend', this.touchend)
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
|
this.$eventBus.$on('close-ebook', this.closeEvt)
|
||||||
document.body.removeEventListener('touchstart', this.touchstart)
|
document.body.removeEventListener('touchstart', this.touchstart)
|
||||||
document.body.removeEventListener('touchend', this.touchend)
|
document.body.removeEventListener('touchend', this.touchend)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,28 +70,3 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tracksTable {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid #474747;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable tr:nth-child(even) {
|
|
||||||
background-color: #2e2e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable tr {
|
|
||||||
background-color: #373838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable td {
|
|
||||||
padding: 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable th {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracks">
|
<template v-for="track in tracks">
|
||||||
<tr :key="track.index">
|
<tr :key="track.index">
|
||||||
<td>{{ track.metadata.filename }}</td>
|
<td>{{ (track.metadata && track.metadata.filename) || track.title || 'Unknown' }}</td>
|
||||||
<td class="font-mono text-center w-16">
|
<td class="font-mono text-center w-16">
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
</td>
|
</td>
|
||||||
@@ -58,28 +58,3 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tracksTable {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid #474747;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable tr:nth-child(even) {
|
|
||||||
background-color: #2e2e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable tr {
|
|
||||||
background-color: #373838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable td {
|
|
||||||
padding: 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracksTable th {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||||
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
|
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex items-center w-full">
|
||||||
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
<div class="book-table-content h-full px-2 flex items-center">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-8 min-w-8 flex justify-center">
|
||||||
|
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
|
||||||
|
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,6 +36,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.book.id
|
||||||
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.book.media || {}
|
return this.book.media || {}
|
||||||
},
|
},
|
||||||
@@ -59,22 +67,29 @@ export default {
|
|||||||
isMissing() {
|
isMissing() {
|
||||||
return this.book.isMissing
|
return this.book.isMissing
|
||||||
},
|
},
|
||||||
isIncomplete() {
|
isInvalid() {
|
||||||
return this.book.isIncomplete
|
return this.book.isInvalid
|
||||||
},
|
|
||||||
numTracks() {
|
|
||||||
return this.book.numTracks
|
|
||||||
},
|
|
||||||
isStreaming() {
|
|
||||||
return this.$store.getters['getIsItemStreaming'](this.book.id)
|
|
||||||
},
|
},
|
||||||
showPlayBtn() {
|
showPlayBtn() {
|
||||||
return !this.isMissing && !this.isIncomplete && !this.isStreaming && this.numTracks
|
return !this.isMissing && !this.isInvalid && this.tracks.length
|
||||||
|
},
|
||||||
|
isStreaming() {
|
||||||
|
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
streamIsPlaying() {
|
||||||
|
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickEdit() {
|
async playClick() {
|
||||||
this.$emit('edit', this.book)
|
await this.$hapticsImpact()
|
||||||
|
if (this.streamIsPlaying) {
|
||||||
|
this.$eventBus.$emit('pause-item')
|
||||||
|
} else {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
@@ -83,7 +98,7 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.book-table-content {
|
.book-table-content {
|
||||||
width: calc(100% - 50px);
|
width: calc(100% - 82px);
|
||||||
max-width: calc(100% - 50px);
|
max-width: calc(100% - 82px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full my-2">
|
||||||
|
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showFiles ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
|
||||||
|
<p class="pr-2">Ebook Files</p>
|
||||||
|
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
|
<span class="text-xs font-mono">{{ ebookFiles.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||||
|
<span class="material-icons text-3xl">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<transition name="slide">
|
||||||
|
<div class="w-full" v-show="showFiles">
|
||||||
|
<table class="text-sm tracksTable">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-4">Filename</th>
|
||||||
|
<th class="text-left px-4 w-16">Read</th>
|
||||||
|
<th v-if="userCanUpdate && !libraryIsAudiobooksOnly" class="text-center w-16"></th>
|
||||||
|
</tr>
|
||||||
|
<template v-for="file in ebookFiles">
|
||||||
|
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :file="file" @read="readEbook" @more="showMore" />
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<modals-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
showFiles: false,
|
||||||
|
showMoreMenu: false,
|
||||||
|
moreMenuItems: [],
|
||||||
|
selectedFile: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
ebookFiles() {
|
||||||
|
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
libraryIsAudiobooksOnly() {
|
||||||
|
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
moreMenuAction(action) {
|
||||||
|
this.showMoreMenu = false
|
||||||
|
if (action === 'updateStatus') {
|
||||||
|
this.updateEbookStatus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showMore({ file, items }) {
|
||||||
|
this.showMoreMenu = true
|
||||||
|
this.selectedFile = file
|
||||||
|
this.moreMenuItems = items
|
||||||
|
},
|
||||||
|
readEbook(fileIno) {
|
||||||
|
this.$store.commit('showReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
|
||||||
|
},
|
||||||
|
clickBar() {
|
||||||
|
this.showFiles = !this.showFiles
|
||||||
|
},
|
||||||
|
updateEbookStatus() {
|
||||||
|
this.processing = true
|
||||||
|
this.$nativeHttp
|
||||||
|
.patch(`/api/items/${this.libraryItemId}/ebook/${this.selectedFile.ino}/status`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Ebook updated')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update ebook', error)
|
||||||
|
this.$toast.error('Failed to update ebook')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4">{{ file.metadata.filename }} <span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom text-base">check_circle</span></td>
|
||||||
|
<td class="text-xs w-16">
|
||||||
|
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
|
||||||
|
</td>
|
||||||
|
<td v-if="contextMenuItems.length" class="text-center">
|
||||||
|
<ui-icon-btn icon="more_vert" borderless @click="clickMore" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItemId: String,
|
||||||
|
showFullPath: Boolean,
|
||||||
|
file: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
isPrimary() {
|
||||||
|
return !this.file.isSupplementary
|
||||||
|
},
|
||||||
|
libraryIsAudiobooksOnly() {
|
||||||
|
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
|
||||||
|
},
|
||||||
|
contextMenuItems() {
|
||||||
|
const items = []
|
||||||
|
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
|
||||||
|
items.push({
|
||||||
|
text: this.isPrimary ? 'Set as supplementary' : 'Set as primary',
|
||||||
|
value: 'updateStatus'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickMore() {
|
||||||
|
this.$emit('more', {
|
||||||
|
file: this.file,
|
||||||
|
items: this.contextMenuItems
|
||||||
|
})
|
||||||
|
},
|
||||||
|
readEbook() {
|
||||||
|
this.$emit('read', this.file.ino)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||||
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex w-full">
|
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex items-center w-full">
|
||||||
<div class="h-full relative" :style="{ width: '50px' }">
|
<div class="h-full relative" :style="{ width: '50px' }">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow item-table-content h-full px-2 flex items-center">
|
<div class="item-table-content h-full px-2 flex items-center">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<p class="truncate block text-sm">{{ itemTitle }}</p>
|
<p class="truncate block text-sm">{{ itemTitle }} <span v-if="localLibraryItem" class="material-icons text-success text-base align-text-bottom">download_done</span></p>
|
||||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
|
<p v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
|
||||||
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
|
<p class="text-xxs text-gray-400">{{ itemDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-8 min-w-8 flex justify-center">
|
||||||
|
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
|
||||||
|
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,11 +39,17 @@ export default {
|
|||||||
libraryItem() {
|
libraryItem() {
|
||||||
return this.item.libraryItem || {}
|
return this.item.libraryItem || {}
|
||||||
},
|
},
|
||||||
|
localLibraryItem() {
|
||||||
|
return this.item.localLibraryItem
|
||||||
|
},
|
||||||
episode() {
|
episode() {
|
||||||
return this.item.episode
|
return this.item.episode
|
||||||
},
|
},
|
||||||
episodeId() {
|
episodeId() {
|
||||||
return this.episode ? this.episode.id : null
|
return this.episode?.id || null
|
||||||
|
},
|
||||||
|
localEpisode() {
|
||||||
|
return this.item.localEpisode
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
@@ -61,6 +72,10 @@ export default {
|
|||||||
bookAuthorName() {
|
bookAuthorName() {
|
||||||
return this.bookAuthors.map((au) => au.name).join(', ')
|
return this.bookAuthors.map((au) => au.name).join(', ')
|
||||||
},
|
},
|
||||||
|
authorName() {
|
||||||
|
if (this.episode) return this.mediaMetadata.author
|
||||||
|
return this.bookAuthorName
|
||||||
|
},
|
||||||
itemDuration() {
|
itemDuration() {
|
||||||
if (this.episode) return this.$elapsedPretty(this.episode.duration)
|
if (this.episode) return this.$elapsedPretty(this.episode.duration)
|
||||||
return this.$elapsedPretty(this.media.duration)
|
return this.$elapsedPretty(this.media.duration)
|
||||||
@@ -83,21 +98,44 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this.libraryItem.isInvalid
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
|
||||||
return this.$store.getters['getIsItemStreaming'](this.item.id)
|
|
||||||
},
|
|
||||||
showPlayBtn() {
|
showPlayBtn() {
|
||||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)
|
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
|
||||||
|
},
|
||||||
|
isStreaming() {
|
||||||
|
if (this.localLibraryItem && this.localEpisode && this.$store.getters['getIsMediaStreaming'](this.localLibraryItem.id, this.localEpisode.id)) return true
|
||||||
|
return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)
|
||||||
|
},
|
||||||
|
streamIsPlaying() {
|
||||||
|
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async playClick() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
if (this.streamIsPlaying) {
|
||||||
|
this.$eventBus.$emit('pause-item')
|
||||||
|
} else if (this.localLibraryItem) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.localLibraryItem.id,
|
||||||
|
episodeId: this.localEpisode?.id,
|
||||||
|
serverLibraryItemId: this.libraryItem.id,
|
||||||
|
serverEpisodeId: this.episodeId
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItem.id,
|
||||||
|
episodeId: this.episodeId
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.item-table-content {
|
.item-table-content {
|
||||||
width: calc(100% - 50px);
|
width: calc(100% - 82px);
|
||||||
max-width: calc(100% - 50px);
|
max-width: calc(100% - 82px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -17,7 +17,15 @@
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-sm text-gray-200 line-clamp-2 mt-1.5 mb-0.5">{{ subtitle }}</p>
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
|
||||||
|
|
||||||
|
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5">
|
||||||
|
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
|
||||||
|
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
|
||||||
|
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200 capitalize">
|
||||||
|
{{ episodeType }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center pt-2">
|
<div class="flex items-center pt-2">
|
||||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||||
@@ -90,20 +98,23 @@ export default {
|
|||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.episode.subtitle || ''
|
return this.episode.subtitle || this.episode.description || ''
|
||||||
|
},
|
||||||
|
episodeNumber() {
|
||||||
|
return this.episode.episode
|
||||||
|
},
|
||||||
|
season() {
|
||||||
|
return this.episode.season
|
||||||
|
},
|
||||||
|
episodeType() {
|
||||||
|
if (this.episode.episodeType === 'full') return null // only show Trailer/Bonus
|
||||||
|
return this.episode.episodeType
|
||||||
},
|
},
|
||||||
duration() {
|
duration() {
|
||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) {
|
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||||
// Check is streaming local version of this episode
|
|
||||||
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
|
|
||||||
}
|
|
||||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
|
||||||
},
|
|
||||||
playerIsLocal() {
|
|
||||||
return !!this.$store.state.playerIsLocal
|
|
||||||
},
|
},
|
||||||
streamIsPlaying() {
|
streamIsPlaying() {
|
||||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||||
@@ -168,29 +179,33 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
var localFolder = selectedLocalFolder
|
let localFolder = selectedLocalFolder
|
||||||
if (!localFolder) {
|
if (!localFolder) {
|
||||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
console.log('Local folders loaded', localFolders.length)
|
console.log('Local folders loaded', localFolders.length)
|
||||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||||
console.log('Checking local folder', lf.mediaType)
|
console.log('Checking local folder', lf.mediaType)
|
||||||
return lf.mediaType == this.mediaType
|
return lf.mediaType == this.mediaType
|
||||||
})
|
})
|
||||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||||
|
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||||
if (!foldersWithMediaType.length) {
|
if (!foldersWithMediaType.length) {
|
||||||
// No local folders or no local folders with this media type
|
localFolder = {
|
||||||
localFolder = await this.selectFolder()
|
id: `internal-${this.mediaType}`,
|
||||||
} else if (foldersWithMediaType.length == 1) {
|
name: 'Internal App Storage',
|
||||||
console.log('Only 1 local folder with this media type - auto select it')
|
mediaType: this.mediaType
|
||||||
localFolder = foldersWithMediaType[0]
|
}
|
||||||
|
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||||
|
localFolder = internalStorageFolder
|
||||||
} else {
|
} else {
|
||||||
console.log('Multiple folders with media type')
|
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||||
// this.showSelectLocalFolder = true
|
mediaType: this.mediaType,
|
||||||
|
callback: (folder) => {
|
||||||
|
this.download(folder)
|
||||||
|
}
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!localFolder) {
|
|
||||||
return this.$toast.error('Invalid download folder')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Local folder', JSON.stringify(localFolder))
|
console.log('Local folder', JSON.stringify(localFolder))
|
||||||
@@ -243,49 +258,37 @@ export default {
|
|||||||
},
|
},
|
||||||
async toggleFinished() {
|
async toggleFinished() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
if (this.isLocal || this.localEpisode) {
|
if (this.isLocal || this.localEpisode) {
|
||||||
var isFinished = !this.userIsFinished
|
const isFinished = !this.userIsFinished
|
||||||
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||||
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||||
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||||
if (!payload || payload.error) {
|
if (payload?.error) {
|
||||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
this.$toast.error(payload?.error || 'Unknown error')
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
} else {
|
} else {
|
||||||
var localMediaProgress = payload.localMediaProgress
|
const localMediaProgress = payload.localMediaProgress
|
||||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||||
if (localMediaProgress) {
|
if (localMediaProgress) {
|
||||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
|
||||||
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
|
|
||||||
|
|
||||||
var serverUpdated = payload.server
|
|
||||||
if (serverUpdated) {
|
|
||||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
} else {
|
|
||||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
} else {
|
} else {
|
||||||
var updatePayload = {
|
const updatePayload = {
|
||||||
isFinished: !this.userIsFinished
|
isFinished: !this.userIsFinished
|
||||||
}
|
}
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
.patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isProcessingReadUpdate = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
|
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
|
||||||
|
|
||||||
<modals-podcast-episodes-feed-modal v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
<modals-podcast-episodes-feed-modal v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||||
|
|
||||||
|
<modals-order-modal v-model="showSortModal" :order-by.sync="sortKey" :descending.sync="sortDesc" episodes />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
episodesCopy: [],
|
episodesCopy: [],
|
||||||
showFiltersModal: false,
|
showFiltersModal: false,
|
||||||
|
showSortModal: false,
|
||||||
sortKey: 'publishedAt',
|
sortKey: 'publishedAt',
|
||||||
sortDesc: true,
|
sortDesc: true,
|
||||||
filterKey: 'incomplete',
|
filterKey: 'incomplete',
|
||||||
@@ -167,10 +170,19 @@ export default {
|
|||||||
},
|
},
|
||||||
episodesSorted() {
|
episodesSorted() {
|
||||||
return this.episodesFiltered.sort((a, b) => {
|
return this.episodesFiltered.sort((a, b) => {
|
||||||
if (this.sortDesc) {
|
let aValue = a[this.sortKey]
|
||||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
let bValue = b[this.sortKey]
|
||||||
|
|
||||||
|
// Sort episodes with no pub date as the oldest
|
||||||
|
if (this.sortKey === 'publishedAt') {
|
||||||
|
if (!aValue) aValue = Number.MAX_VALUE
|
||||||
|
if (!bValue) bValue = Number.MAX_VALUE
|
||||||
}
|
}
|
||||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
|
||||||
|
if (this.sortDesc) {
|
||||||
|
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
}
|
||||||
|
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// Map of local episodes where server episode id is key
|
// Map of local episodes where server episode id is key
|
||||||
@@ -185,9 +197,8 @@ export default {
|
|||||||
},
|
},
|
||||||
sortText() {
|
sortText() {
|
||||||
if (!this.sortKey) return ''
|
if (!this.sortKey) return ''
|
||||||
var _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||||
if (!_sel) return ''
|
return _sel?.text || ''
|
||||||
return _sel.text
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -198,8 +209,8 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
.get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Episode download queue cleared')
|
this.$toast.success('Episode download queue cleared')
|
||||||
this.episodeDownloadQueued = []
|
this.episodeDownloadQueued = []
|
||||||
@@ -219,7 +230,7 @@ export default {
|
|||||||
return this.$toast.error('Podcast does not have an RSS Feed')
|
return this.$toast.error('Podcast does not have an RSS Feed')
|
||||||
}
|
}
|
||||||
this.fetchingRSSFeed = true
|
this.fetchingRSSFeed = true
|
||||||
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
|
const payload = await this.$nativeHttp.post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
|
||||||
console.error('Failed to get feed', error)
|
console.error('Failed to get feed', error)
|
||||||
this.$toast.error('Failed to get podcast feed')
|
this.$toast.error('Failed to get podcast feed')
|
||||||
return null
|
return null
|
||||||
@@ -249,7 +260,7 @@ export default {
|
|||||||
this.showFiltersModal = true
|
this.showFiltersModal = true
|
||||||
},
|
},
|
||||||
clickSort() {
|
clickSort() {
|
||||||
this.sortDesc = !this.sortDesc
|
this.showSortModal = true
|
||||||
},
|
},
|
||||||
getEpisodeProgress(episode) {
|
getEpisodeProgress(episode) {
|
||||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
|
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
|
||||||
|
|||||||
@@ -16,7 +16,16 @@
|
|||||||
<p class="text-sm font-semibold">
|
<p class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-200 line-clamp-2 mt-1.5 mb-0.5">{{ subtitle }}</p>
|
|
||||||
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
|
||||||
|
|
||||||
|
<div v-if="episodeNumber || season || episodeType" class="flex pt-2 items-center -mx-0.5">
|
||||||
|
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
|
||||||
|
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
|
||||||
|
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200 capitalize">
|
||||||
|
{{ episodeType }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center pt-2">
|
<div class="flex items-center pt-2">
|
||||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||||
@@ -92,20 +101,23 @@ export default {
|
|||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.episode.subtitle || ''
|
return this.episode.subtitle || this.episode.description || ''
|
||||||
|
},
|
||||||
|
episodeNumber() {
|
||||||
|
return this.episode.episode
|
||||||
|
},
|
||||||
|
season() {
|
||||||
|
return this.episode.season
|
||||||
|
},
|
||||||
|
episodeType() {
|
||||||
|
if (this.episode.episodeType === 'full') return null // only show Trailer/Bonus
|
||||||
|
return this.episode.episodeType
|
||||||
},
|
},
|
||||||
duration() {
|
duration() {
|
||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) {
|
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||||
// Check is streaming local version of this episode
|
|
||||||
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
|
|
||||||
}
|
|
||||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
|
||||||
},
|
|
||||||
playerIsLocal() {
|
|
||||||
return !!this.$store.state.playerIsLocal
|
|
||||||
},
|
},
|
||||||
streamIsPlaying() {
|
streamIsPlaying() {
|
||||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||||
@@ -173,29 +185,33 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
var localFolder = selectedLocalFolder
|
let localFolder = selectedLocalFolder
|
||||||
if (!localFolder) {
|
if (!localFolder) {
|
||||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
console.log('Local folders loaded', localFolders.length)
|
console.log('Local folders loaded', localFolders.length)
|
||||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||||
console.log('Checking local folder', lf.mediaType)
|
console.log('Checking local folder', lf.mediaType)
|
||||||
return lf.mediaType == this.mediaType
|
return lf.mediaType == this.mediaType
|
||||||
})
|
})
|
||||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||||
|
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||||
if (!foldersWithMediaType.length) {
|
if (!foldersWithMediaType.length) {
|
||||||
// No local folders or no local folders with this media type
|
localFolder = {
|
||||||
localFolder = await this.selectFolder()
|
id: `internal-${this.mediaType}`,
|
||||||
} else if (foldersWithMediaType.length == 1) {
|
name: 'Internal App Storage',
|
||||||
console.log('Only 1 local folder with this media type - auto select it')
|
mediaType: this.mediaType
|
||||||
localFolder = foldersWithMediaType[0]
|
}
|
||||||
|
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||||
|
localFolder = internalStorageFolder
|
||||||
} else {
|
} else {
|
||||||
console.log('Multiple folders with media type')
|
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||||
// this.showSelectLocalFolder = true
|
mediaType: this.mediaType,
|
||||||
|
callback: (folder) => {
|
||||||
|
this.download(folder)
|
||||||
|
}
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!localFolder) {
|
|
||||||
return this.$toast.error('Invalid download folder')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Local folder', JSON.stringify(localFolder))
|
console.log('Local folder', JSON.stringify(localFolder))
|
||||||
@@ -248,49 +264,37 @@ export default {
|
|||||||
},
|
},
|
||||||
async toggleFinished() {
|
async toggleFinished() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
if (this.isLocal || this.localEpisode) {
|
if (this.isLocal || this.localEpisode) {
|
||||||
var isFinished = !this.userIsFinished
|
const isFinished = !this.userIsFinished
|
||||||
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||||
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||||
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||||
if (!payload || payload.error) {
|
if (payload?.error) {
|
||||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
this.$toast.error(payload?.error || 'Unknown error')
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
} else {
|
} else {
|
||||||
var localMediaProgress = payload.localMediaProgress
|
const localMediaProgress = payload.localMediaProgress
|
||||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||||
if (localMediaProgress) {
|
if (localMediaProgress) {
|
||||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
|
||||||
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
|
|
||||||
|
|
||||||
var serverUpdated = payload.server
|
|
||||||
if (serverUpdated) {
|
|
||||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
} else {
|
|
||||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
} else {
|
} else {
|
||||||
var updatePayload = {
|
const updatePayload = {
|
||||||
isFinished: !this.userIsFinished
|
isFinished: !this.userIsFinished
|
||||||
}
|
}
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
.patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isProcessingReadUpdate = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex">
|
||||||
|
<input v-model="input" type="range" :min="min" :max="max" :step="step" :style="{ width: inputWidth }" />
|
||||||
|
|
||||||
|
<p class="text-xs ml-2">{{ input }}%</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
min: Number,
|
||||||
|
max: Number,
|
||||||
|
step: Number,
|
||||||
|
inputWidth: {
|
||||||
|
type: String,
|
||||||
|
default: 'unset'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
input: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input[type='range'] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type='range']:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* chromium */
|
||||||
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
|
background-color: rgb(0 0 0 / 0.25);
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: rgb(255 255 255 / 0.7);
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
input[type='range']:focus::-webkit-slider-thumb {
|
||||||
|
border: 1px solid #6b6b6b;
|
||||||
|
outline: 3px solid #6b6b6b;
|
||||||
|
outline-offset: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* firefox */
|
||||||
|
input[type='range']::-moz-range-track {
|
||||||
|
background-color: rgb(0 0 0 / 0.25);
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
background-color: rgb(255 255 255 / 0.7);
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
input[type='range']:focus::-moz-range-thumb {
|
||||||
|
border: 1px solid #6b6b6b;
|
||||||
|
outline: 3px solid #6b6b6b;
|
||||||
|
outline-offset: 0.125rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex toggle-btn-wrapper shadow-md">
|
||||||
|
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||||
|
{{ item.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
/**
|
||||||
|
* [{ "text", "", "value": "" }]
|
||||||
|
*/
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: Object
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
clickBtn(value) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toggle-btn-wrapper .toggle-btn:first-child {
|
||||||
|
border-top-left-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
.toggle-btn-wrapper .toggle-btn:last-child {
|
||||||
|
border-top-right-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
.toggle-btn-wrapper .toggle-btn:first-child::before {
|
||||||
|
border-top-left-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
.toggle-btn-wrapper .toggle-btn:last-child::before {
|
||||||
|
border-top-right-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
|
||||||
|
margin-left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0);
|
||||||
|
transition: all 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
.toggle-btn:hover:not(:disabled)::before {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.toggle-btn:hover:not(:disabled) {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
.toggle-btn.selected {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.toggle-btn.selected::before {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
button.toggle-btn:disabled::before {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="box" class="inline-block" @click.stop="click">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
text: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
type: String,
|
||||||
|
default: 'right'
|
||||||
|
},
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tooltip: null,
|
||||||
|
tooltipTextEl: null,
|
||||||
|
tooltipId: null,
|
||||||
|
isShowing: false,
|
||||||
|
hideTimeout: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
text() {
|
||||||
|
this.updateText()
|
||||||
|
},
|
||||||
|
disabled(newVal) {
|
||||||
|
if (newVal && this.isShowing) {
|
||||||
|
this.hideTooltip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateText() {
|
||||||
|
if (this.tooltipTextEl) {
|
||||||
|
this.tooltipTextEl.innerHTML = this.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createTooltip() {
|
||||||
|
if (!this.$refs.box) return
|
||||||
|
const tooltip = document.createElement('div')
|
||||||
|
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
||||||
|
tooltip.id = this.tooltipId
|
||||||
|
tooltip.className = 'fixed inset-0 w-screen h-screen bg-black/25 text-xs flex items-center justify-center p-2'
|
||||||
|
tooltip.style.zIndex = 100
|
||||||
|
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||||
|
|
||||||
|
tooltip.addEventListener('click', this.hideTooltip)
|
||||||
|
|
||||||
|
const innerDiv = document.createElement('div')
|
||||||
|
innerDiv.className = 'w-full p-2 border border-white/20 pointer-events-none text-white bg-primary'
|
||||||
|
innerDiv.innerHTML = this.text
|
||||||
|
tooltip.appendChild(innerDiv)
|
||||||
|
|
||||||
|
this.tooltipTextEl = innerDiv
|
||||||
|
this.tooltip = tooltip
|
||||||
|
},
|
||||||
|
showTooltip() {
|
||||||
|
if (this.disabled) return
|
||||||
|
if (!this.tooltip) {
|
||||||
|
this.createTooltip()
|
||||||
|
if (!this.tooltip) return
|
||||||
|
}
|
||||||
|
if (!this.$refs.box) return // Ensure element is not destroyed
|
||||||
|
try {
|
||||||
|
document.body.appendChild(this.tooltip)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isShowing = true
|
||||||
|
},
|
||||||
|
hideTooltip() {
|
||||||
|
if (!this.tooltip) return
|
||||||
|
this.tooltip.remove()
|
||||||
|
this.isShowing = false
|
||||||
|
},
|
||||||
|
click() {
|
||||||
|
if (!this.isShowing) this.showTooltip()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.hideTooltip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -30,10 +30,15 @@ export default {
|
|||||||
isCellular() {
|
isCellular() {
|
||||||
return this.networkConnectionType === 'cellular'
|
return this.networkConnectionType === 'cellular'
|
||||||
},
|
},
|
||||||
|
attemptingConnection() {
|
||||||
|
return this.$store.state.attemptingConnection
|
||||||
|
},
|
||||||
icon() {
|
icon() {
|
||||||
if (!this.user) return null // hide when not connected to server
|
if (!this.user && !this.attemptingConnection) return null // hide when not connected to server
|
||||||
|
|
||||||
if (!this.networkConnected) {
|
if (this.attemptingConnection) {
|
||||||
|
return 'cloud_sync'
|
||||||
|
} else if (!this.networkConnected) {
|
||||||
return 'wifi_off'
|
return 'wifi_off'
|
||||||
} else if (!this.socketConnected) {
|
} else if (!this.socketConnected) {
|
||||||
return 'cloud_off'
|
return 'cloud_off'
|
||||||
@@ -55,7 +60,9 @@ export default {
|
|||||||
showAlertDialog() {
|
showAlertDialog() {
|
||||||
var msg = ''
|
var msg = ''
|
||||||
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
|
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
|
||||||
if (!this.networkConnected) {
|
if (this.attemptingConnection) {
|
||||||
|
msg = 'Attempting server connection'
|
||||||
|
} else if (!this.networkConnected) {
|
||||||
msg = 'No internet'
|
msg = 'No internet'
|
||||||
} else if (!this.socketConnected) {
|
} else if (!this.socketConnected) {
|
||||||
msg = 'Socket not connected'
|
msg = 'Socket not connected'
|
||||||
|
|||||||
+1
-1
@@ -1,9 +1,9 @@
|
|||||||
App/build
|
App/build
|
||||||
App/Pods
|
App/Pods
|
||||||
App/Podfile.lock
|
|
||||||
App/App/public
|
App/App/public
|
||||||
DerivedData
|
DerivedData
|
||||||
xcuserdata
|
xcuserdata
|
||||||
|
|
||||||
# Cordova plugins for Capacitor
|
# Cordova plugins for Capacitor
|
||||||
capacitor-cordova-ios-plugins
|
capacitor-cordova-ios-plugins
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user