mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-25 14:08:35 +02:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dbffa7993 | ||
|
|
17ff2ed5f4 | ||
|
|
6fe470cfc1 | ||
|
|
301e9b213f | ||
|
|
2b533524fc | ||
|
|
632b1f526b | ||
|
|
81b11bac93 | ||
|
|
a88e404ccf | ||
|
|
a7cbdbfbbb | ||
|
|
ad5146e2ee | ||
|
|
36be91962c | ||
|
|
ff5a1bb09f | ||
|
|
5060554640 | ||
|
|
93cb319e97 | ||
|
|
a42dfa5649 | ||
|
|
6a938b8da1 | ||
|
|
4f994072ad | ||
|
|
7c0ef179ec | ||
|
|
5fdd0c6672 | ||
|
|
e9f523d7cb | ||
|
|
c1b13dcd24 | ||
|
|
0daa043f14 | ||
|
|
1a6b716046 | ||
|
|
945baa24f0 | ||
|
|
30d4e709f0 | ||
|
|
8d8782a5a9 | ||
|
|
087e8553c4 | ||
|
|
2984398051 | ||
|
|
e9251db647 | ||
|
|
6c88337180 | ||
|
|
74b488ad0f | ||
|
|
fc7af6d1fc | ||
|
|
737d8f19b3 | ||
|
|
d613d8954d | ||
|
|
04e468b43d | ||
|
|
dc8178769b | ||
|
|
8b631f7860 | ||
|
|
e521ddfab6 | ||
|
|
6b164bdb27 | ||
|
|
2eb399b8a7 | ||
|
|
46f558d6e0 | ||
|
|
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 |
@@ -21,6 +21,12 @@ jobs:
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ jobs:
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
|
||||
@@ -33,9 +33,12 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 96
|
||||
versionName "0.9.65-beta"
|
||||
versionCode 98
|
||||
versionName "0.9.67-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
]
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
@@ -63,12 +66,11 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
configurations.configureEach {
|
||||
resolutionStrategy {
|
||||
force("com.google.android.gms:play-services-base:18.0.1")
|
||||
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
||||
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("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||
@@ -81,7 +83,7 @@ dependencies {
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
@@ -111,7 +113,6 @@ dependencies {
|
||||
|
||||
// Glide for images
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||
|
||||
// Paper NoSQL Db
|
||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
@@ -124,9 +125,6 @@ dependencies {
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||
|
||||
// FFMPEG-Kit
|
||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-clipboard')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-haptics')
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">audiobookshelf</string>
|
||||
<string name="title_activity_main">audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">audiobookshelf</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||
<string name="action_jump_forward">Jump Forward</string>
|
||||
<string name="action_jump_backward">Jump Backward</string>
|
||||
<string name="action_skip_forward">Skip Forward</string>
|
||||
<string name="action_skip_backward">Skip Backward</string>
|
||||
<string name="action_change_speed">Change Playback Speed</string>
|
||||
</resources>
|
||||
@@ -2,5 +2,10 @@
|
||||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/browser",
|
||||
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/clipboard",
|
||||
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||
|
||||
@@ -95,7 +95,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
||||
|
||||
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)
|
||||
|
||||
// Instruct the widget manager to update the widget
|
||||
|
||||
@@ -24,7 +24,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
||||
@JsonIgnore
|
||||
open fun removeAudioTrack(localFileId:String) { }
|
||||
@JsonIgnore
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ class Book(
|
||||
JsonSubTypes.Type(BookMetadata::class),
|
||||
JsonSubTypes.Type(PodcastMetadata::class)
|
||||
)
|
||||
open class MediaTypeMetadata(var title:String) {
|
||||
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
|
||||
@JsonIgnore
|
||||
open fun getAuthorDisplayName():String { return "Unknown" }
|
||||
}
|
||||
@@ -209,13 +209,13 @@ class BookMetadata(
|
||||
var isbn:String?,
|
||||
var asin:String?,
|
||||
var language:String?,
|
||||
var explicit:Boolean,
|
||||
explicit:Boolean,
|
||||
// In toJSONExpanded
|
||||
var authorName:String?,
|
||||
var authorNameLF:String?,
|
||||
var narratorName:String?,
|
||||
var seriesName:String?
|
||||
) : MediaTypeMetadata(title) {
|
||||
) : MediaTypeMetadata(title, explicit) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||
}
|
||||
@@ -225,8 +225,9 @@ class PodcastMetadata(
|
||||
title:String,
|
||||
var author:String?,
|
||||
var feedUrl:String?,
|
||||
var genres:MutableList<String>
|
||||
) : MediaTypeMetadata(title) {
|
||||
var genres:MutableList<String>,
|
||||
explicit:Boolean
|
||||
) : MediaTypeMetadata(title, explicit) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
|
||||
@@ -152,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()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
|
||||
@@ -43,7 +43,7 @@ data class LocalMediaItem(
|
||||
return if (mediaType == "book") {
|
||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
||||
} else {
|
||||
PodcastMetadata(name,null,null, mutableListOf())
|
||||
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,10 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.FFprobeKit
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.JSObject
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
@@ -27,211 +21,6 @@ class FolderScanner(var ctx: Context) {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
enum class ItemScanResult {
|
||||
ADDED, REMOVED, UPDATED, UPTODATE
|
||||
}
|
||||
|
||||
// TODO: CLEAN this monster! Divide into bite-size methods
|
||||
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
|
||||
FFmpegKitConfig.enableLogCallback { log ->
|
||||
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
|
||||
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
|
||||
return null
|
||||
}
|
||||
|
||||
var mediaItemsUpdated = 0
|
||||
var mediaItemsAdded = 0
|
||||
var mediaItemsRemoved = 0
|
||||
var mediaItemsUpToDate = 0
|
||||
|
||||
// Search for files in media item folder
|
||||
val foldersFound = df.search(true, DocumentFileType.FOLDER)
|
||||
|
||||
// Match folders found with local library items already saved in db
|
||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
||||
|
||||
// Remove existing items no longer there
|
||||
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
|
||||
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
|
||||
val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
|
||||
if (fileFound == null) {
|
||||
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
|
||||
mediaItemsRemoved++
|
||||
}
|
||||
fileFound != null
|
||||
}
|
||||
|
||||
foldersFound.forEach { itemFolder ->
|
||||
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 filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
|
||||
// Do not scan folders that have no media items and not an existing item already
|
||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||
when (scanLibraryItemFolder(itemFolder, filesInFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
||||
|
||||
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
|
||||
val folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
||||
} else {
|
||||
Log.d(tag, "No Media Items to save")
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, filesInFolder:List<DocumentFile>, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
val itemFolderName = itemFolder.name ?: ""
|
||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
||||
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
|
||||
var isNewOrUpdated = existingItem == null
|
||||
|
||||
val audioTracks = mutableListOf<AudioTrack>()
|
||||
val localFiles = mutableListOf<LocalFile>()
|
||||
var index = 1
|
||||
var startOffset = 0.0
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
var hasEBookFile = false
|
||||
var newEBookFile:EBookFile? = null
|
||||
|
||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
}
|
||||
if (existingLocalFilesRemoved.isNotEmpty()) {
|
||||
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
|
||||
filesInFolder.forEach { file ->
|
||||
val mimeType = file.mimeType ?: ""
|
||||
val filename = file.name ?: ""
|
||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
||||
|
||||
val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
|
||||
localFiles.add(localFile)
|
||||
|
||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||
|
||||
if (localFile.isAudioFile()) {
|
||||
val audioTrackToAdd:AudioTrack?
|
||||
|
||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||
if (existingAudioTrack != null) { // Update existing audio track
|
||||
if (existingAudioTrack.index != index) {
|
||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
|
||||
existingAudioTrack.index = index
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
if (existingAudioTrack.startOffset != startOffset) {
|
||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
|
||||
existingAudioTrack.startOffset = startOffset
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAudioTrack == null || forceAudioProbe) {
|
||||
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
|
||||
|
||||
// TODO: Make asynchronous
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
if (existingAudioTrack != null) {
|
||||
// Update audio probe data on existing audio track
|
||||
existingAudioTrack.audioProbeResult = audioProbeResult
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
} else {
|
||||
// Create new audio track
|
||||
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
||||
audioTrackToAdd = track
|
||||
}
|
||||
|
||||
startOffset += audioProbeResult?.duration ?: 0.0
|
||||
isNewOrUpdated = true
|
||||
} else {
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
}
|
||||
|
||||
startOffset += audioTrackToAdd.duration
|
||||
index++
|
||||
audioTracks.add(audioTrackToAdd)
|
||||
} else if (localFile.isEBookFile()) {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
if (localFolder.mediaType == "book") {
|
||||
hasEBookFile = true
|
||||
if (existingLocalFile == null) {
|
||||
newEBookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null) {
|
||||
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
if (existingItem != null && existingItem.coverContentUrl == null) {
|
||||
// Existing media item did not have a cover - cover found on scan
|
||||
Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}")
|
||||
isNewOrUpdated = true
|
||||
existingItem.coverAbsolutePath = localFile.absolutePath
|
||||
existingItem.coverContentUrl = localFile.contentUrl
|
||||
existingItem.media.coverPath = localFile.absolutePath
|
||||
}
|
||||
|
||||
// First image file use as cover path
|
||||
if (coverContentUrl == null) {
|
||||
coverContentUrl = localFile.contentUrl
|
||||
coverAbsolutePath = localFile.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem != null && audioTracks.isEmpty() && !hasEBookFile) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||
return ItemScanResult.REMOVED
|
||||
} else if (existingItem != null && !isNewOrUpdated) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
|
||||
return ItemScanResult.UPTODATE
|
||||
} else if (existingItem != null) {
|
||||
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
|
||||
existingItem.updateFromScan(audioTracks,localFiles)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
||||
return ItemScanResult.UPDATED
|
||||
} else if (audioTracks.isNotEmpty() || newEBookFile != null) {
|
||||
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,newEBookFile,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
return ItemScanResult.ADDED
|
||||
} else {
|
||||
return ItemScanResult.UPTODATE
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
@@ -385,7 +174,7 @@ class FolderScanner(var ctx: Context) {
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = false,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
@@ -552,7 +341,7 @@ class FolderScanner(var ctx: Context) {
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = false,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
@@ -564,6 +353,7 @@ class FolderScanner(var ctx: Context) {
|
||||
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
|
||||
@@ -573,132 +363,4 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
}
|
||||
|
||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
||||
return null
|
||||
}
|
||||
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
|
||||
|
||||
var wasUpdated = false
|
||||
|
||||
// Search for files in media item folder
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||
|
||||
filesFound.forEach {
|
||||
try {
|
||||
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
|
||||
}catch(e:Exception) {
|
||||
Log.d(tag, "Check file found exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
val existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
||||
|
||||
// Remove any files no longer found in library item folder
|
||||
val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
||||
existingLocalFileIds.forEach { localFileId ->
|
||||
Log.d(tag, "Checking local file id is there $localFileId")
|
||||
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
||||
|
||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
||||
Log.d(tag, "scanLocalLibraryItem audio track file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.media.removeAudioTrack(localFileId)
|
||||
}
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
||||
|
||||
val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
if (existingLocalFile == null) {
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
||||
}
|
||||
|
||||
if (localFile.isAudioFile()) {
|
||||
// TODO: Make asynchronous
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||
audioTrack.localFileId == localFileId
|
||||
}
|
||||
|
||||
if (existingTrack == null) {
|
||||
// Create new audio track
|
||||
val lastTrack = existingAudioTracks.lastOrNull()
|
||||
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
||||
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
||||
localLibraryItem.media.addAudioTrack(track)
|
||||
Log.d(tag, "Added New Audio Track ${track.title}")
|
||||
wasUpdated = true
|
||||
} else {
|
||||
existingTrack.audioProbeResult = audioProbeResult
|
||||
// TODO: Update data found from probe
|
||||
|
||||
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
|
||||
|
||||
wasUpdated = true
|
||||
}
|
||||
} else if (localFile.isEBookFile()) {
|
||||
if (localLibraryItem.mediaType == "book") {
|
||||
val existingEbookFile = (localLibraryItem.media as Book).ebookFile
|
||||
if (existingEbookFile == null || existingEbookFile.localFileId != localFileId) {
|
||||
val ebookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanLocalLibraryItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
} else { // Check if cover is empty
|
||||
if (localLibraryItem.coverContentUrl == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wasUpdated) {
|
||||
Log.d(tag, "Local library item was updated - saving it")
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
} else {
|
||||
Log.d(tag, "Local library item was up-to-date")
|
||||
}
|
||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
||||
}
|
||||
|
||||
private fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||
|
||||
var probeObject:JSObject? = null
|
||||
try {
|
||||
probeObject = JSObject(session.output)
|
||||
} catch(error:JSONException) {
|
||||
Log.e(tag, "Failed to parse probe result $error")
|
||||
}
|
||||
|
||||
Log.d(tag, "FFprobe output $probeObject")
|
||||
return if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||
null
|
||||
} else {
|
||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
audioProbeResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
|
||||
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()
|
||||
|
||||
@@ -86,7 +86,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
playerNotificationService.seekPlayer(pos)
|
||||
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
|
||||
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
|
||||
}
|
||||
|
||||
private fun onChangeSpeed() {
|
||||
|
||||
+51
-45
@@ -25,7 +25,6 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.data.DeviceInfo
|
||||
@@ -65,7 +64,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||
}
|
||||
|
||||
private val tag = "PlayerNotificationService"
|
||||
private val tag = "PlayerNotificationServ"
|
||||
|
||||
interface ClientEventEmitter {
|
||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
||||
@@ -653,16 +652,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentTime() : Long {
|
||||
fun getCurrentTrackStartOffsetMs() : Long {
|
||||
return if (currentPlayer.mediaItemCount > 1) {
|
||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
||||
currentPlayer.currentPosition + currentTrackStartOffset
|
||||
currentTrackStartOffset
|
||||
} else {
|
||||
currentPlayer.currentPosition
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentTime() : Long {
|
||||
return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs()
|
||||
}
|
||||
|
||||
fun getCurrentTimeSeconds() : Double {
|
||||
return getCurrentTime() / 1000.0
|
||||
}
|
||||
@@ -938,7 +941,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
||||
}
|
||||
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
@@ -995,13 +998,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putBoolean(
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||
)
|
||||
extras.putInt(
|
||||
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(
|
||||
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)
|
||||
}
|
||||
@@ -1012,29 +1018,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
result.detach()
|
||||
|
||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||
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
|
||||
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||
@@ -1062,19 +1046,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val progress: MediaProgressWrapper?
|
||||
val mediaDescription:MediaDescriptionCompat
|
||||
if (itemInProgress.episode != null) {
|
||||
if (itemInProgress.isLocal) {
|
||||
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||
if (itemInProgress.isLocal) {
|
||||
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||
|
||||
// to show download icon
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||
localLibraryItem?.let { lli ->
|
||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
||||
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
||||
}
|
||||
// to show download icon
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||
localLibraryItem?.let { lli ->
|
||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
||||
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
||||
} else {
|
||||
if (itemInProgress.isLocal) {
|
||||
@@ -1090,16 +1074,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
result.sendResult(localBrowseItems)
|
||||
} else {
|
||||
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) {
|
||||
mediaManager.loadAndroidAutoItems {
|
||||
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||
|
||||
val children = browseTree[parentMediaId]?.map { item ->
|
||||
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>?)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,10 @@ class AbsDatabase : Plugin() {
|
||||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
// Remove all local sessions
|
||||
savedSessions.forEach {
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,11 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@CapacitorPlugin(name = "AbsFileSystem")
|
||||
@@ -171,26 +166,6 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve(jsObject)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanFolder(call: PluginCall) {
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
|
||||
|
||||
val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.let {
|
||||
val folderScanner = FolderScanner(context)
|
||||
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
||||
if (folderScanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
return call.resolve(JSObject())
|
||||
} else {
|
||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}")
|
||||
return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult)))
|
||||
}
|
||||
} ?: call.resolve(JSObject())
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeFolder(call: PluginCall) {
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
@@ -205,27 +180,6 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanLocalLibraryItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.let {
|
||||
val folderScanner = FolderScanner(context)
|
||||
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
||||
if (scanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
call.resolve(JSObject())
|
||||
} else {
|
||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}")
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult)))
|
||||
}
|
||||
} ?: call.resolve(JSObject())
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||
|
||||
@@ -323,13 +323,15 @@ class ApiHandler(var ctx:Context) {
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
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) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
Log.e(tag, "Failed to sync local sessions")
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||
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 ->
|
||||
if (localSessionSyncResult.progressSynced == true) {
|
||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||
@@ -338,7 +340,6 @@ class ApiHandler(var ctx:Context) {
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
}
|
||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
||||
}
|
||||
}
|
||||
cb(true, null)
|
||||
@@ -364,6 +365,7 @@ 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}")
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
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}"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<string name="app_name">audiobookshelf</string>
|
||||
<string name="title_activity_main">audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">audiobookshelf</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||
<string name="action_jump_forward">Jump Forward</string>
|
||||
|
||||
@@ -8,8 +8,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.3.13'
|
||||
classpath 'com.android.tools.build:gradle:7.4.0'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// 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'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-browser'
|
||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||
|
||||
include ':capacitor-clipboard'
|
||||
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 \
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
|
||||
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
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
|
||||
networkTimeout=10000
|
||||
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");
|
||||
# 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
|
||||
# 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"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
# Resolve links: $0 may be a link
|
||||
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.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
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 [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
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."
|
||||
fi
|
||||
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.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
@@ -106,80 +140,105 @@ location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 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" ;;
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --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
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
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" "$@"
|
||||
|
||||
Vendored
+9
-6
@@ -14,7 +14,7 @@
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@@ -25,7 +25,8 @@
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
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
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
+12
-12
@@ -1,19 +1,19 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 33
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 33
|
||||
androidxActivityVersion = '1.4.0'
|
||||
androidxAppCompatVersion = '1.4.2'
|
||||
androidxActivityVersion = '1.7.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.8.0'
|
||||
androidxCoreVersion = '1.10.0'
|
||||
androidPlayCore = '1.9.0'
|
||||
androidxFragmentVersion = '1.4.1'
|
||||
androidxFragmentVersion = '1.5.6'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.3'
|
||||
androidxEspressoCoreVersion = '3.4.0'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
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_preference_version = '1.1.1'
|
||||
androidx_test_runner_version = '1.3.0'
|
||||
@@ -27,13 +27,13 @@ ext {
|
||||
gradle_version = '3.1.4'
|
||||
gson_version = '2.8.5'
|
||||
junit_version = '4.13'
|
||||
kotlin_version = '1.7.20'
|
||||
kotlin_coroutines_version = '1.1.0'
|
||||
kotlin_version = '1.8.10'
|
||||
kotlin_coroutines_version = '1.6.4'
|
||||
multidex_version = '1.0.3'
|
||||
play_services_auth_version = '18.1.0'
|
||||
recycler_view_version = '1.1.0'
|
||||
robolectric_version = '4.2'
|
||||
test_runner_version = '1.1.0'
|
||||
coreSplashScreenVersion = '1.0.0-rc01'
|
||||
androidxWebkitVersion = '1.4.0'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.6.1'
|
||||
}
|
||||
|
||||
@@ -2,5 +2,10 @@
|
||||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && isServerItem && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<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 -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
},
|
||||
sleepTimerRunning: Boolean,
|
||||
sleepTimeRemaining: Number,
|
||||
isServerItem: Boolean
|
||||
serverLibraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -391,7 +391,7 @@ export default {
|
||||
},
|
||||
clickTitleAndAuthor() {
|
||||
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) {
|
||||
this.$router.push(`/item/${llid}`)
|
||||
this.showFullscreen = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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-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" />
|
||||
@@ -256,7 +256,7 @@ export default {
|
||||
})
|
||||
},
|
||||
pauseItem() {
|
||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
|
||||
if (this.$refs.audioPlayer && this.$refs.audioPlayer.isPlaying) {
|
||||
this.$refs.audioPlayer.pause()
|
||||
}
|
||||
},
|
||||
@@ -273,7 +273,7 @@ export default {
|
||||
this.notifyOnReady()
|
||||
},
|
||||
notifyOnReady() {
|
||||
// TODO: iOS opens last active playback session on app launch. Should be consistent with Android
|
||||
// TODO: was used on iOS to open last played media. May be removed
|
||||
if (!this.isIos) return
|
||||
|
||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||
@@ -285,6 +285,53 @@ export default {
|
||||
},
|
||||
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() {
|
||||
@@ -303,6 +350,7 @@ export default {
|
||||
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||
@@ -317,6 +365,7 @@ export default {
|
||||
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -11,7 +11,11 @@
|
||||
|
||||
<div class="w-full overflow-y-auto">
|
||||
<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>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
@@ -24,9 +28,9 @@
|
||||
<div class="flex items-center">
|
||||
<p class="text-xs">{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="user" class="flex items-center" @click="logout">
|
||||
<p class="text-xs pr-2">Logout</p>
|
||||
<span class="material-icons text-sm">logout</span>
|
||||
<div v-if="user" class="flex items-center" @click="disconnect">
|
||||
<p class="text-xs pr-2">Disconnect</p>
|
||||
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +130,15 @@ export default {
|
||||
text: 'Settings',
|
||||
to: '/settings'
|
||||
})
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
icon: 'login',
|
||||
text: 'Switch Server/User',
|
||||
action: 'logout'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
currentRoutePath() {
|
||||
@@ -133,14 +146,20 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickAction(action) {
|
||||
await this.$hapticsImpact()
|
||||
if (action === 'logout') {
|
||||
await this.logout()
|
||||
this.$router.push('/connect')
|
||||
}
|
||||
},
|
||||
clickBackground() {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
||||
console.error('Failed to logout', error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,7 +167,17 @@ export default {
|
||||
await this.$db.logout()
|
||||
this.$localStore.removeLastLibraryId()
|
||||
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) {
|
||||
this.touchEvent = new TouchEvent(e)
|
||||
|
||||
@@ -72,6 +72,9 @@ export default {
|
||||
showBookshelfListView() {
|
||||
return this.isBookEntity && this.bookshelfListView
|
||||
},
|
||||
sortingIgnorePrefix() {
|
||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||
},
|
||||
entityName() {
|
||||
return this.page
|
||||
},
|
||||
@@ -164,9 +167,9 @@ export default {
|
||||
|
||||
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
|
||||
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)
|
||||
return null
|
||||
})
|
||||
@@ -332,6 +335,12 @@ export default {
|
||||
},
|
||||
async init() {
|
||||
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)
|
||||
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
|
||||
@@ -358,8 +367,14 @@ export default {
|
||||
this.handleScroll(scrollTop)
|
||||
},
|
||||
buildSearchParams() {
|
||||
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
|
||||
if (this.page === 'search' || this.page === 'collections') {
|
||||
return ''
|
||||
} else if (this.page === 'series') {
|
||||
// Sort by name ascending
|
||||
let searchParams = new URLSearchParams()
|
||||
searchParams.set('sort', 'name')
|
||||
searchParams.set('desc', 0)
|
||||
return searchParams.toString()
|
||||
}
|
||||
|
||||
let searchParams = new URLSearchParams()
|
||||
|
||||
@@ -58,24 +58,7 @@ export default {
|
||||
return this._author.numBooks || 0
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -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' }">
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</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>
|
||||
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
@@ -75,9 +81,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -112,7 +123,6 @@ export default {
|
||||
isProcessingReadUpdate: false,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false,
|
||||
@@ -129,9 +139,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
@@ -180,6 +187,10 @@ export default {
|
||||
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||
return this.media.numEpisodes
|
||||
},
|
||||
numEpisodesIncomplete() {
|
||||
if (this.isLocal) return 0
|
||||
return this._libraryItem.numEpisodesIncomplete || 0
|
||||
},
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
@@ -306,14 +317,10 @@ export default {
|
||||
return this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
},
|
||||
isStreaming() {
|
||||
if (this.isPodcast) {
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode.id)
|
||||
} else {
|
||||
return false // not yet necessary for books
|
||||
}
|
||||
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode?.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
return this.store.state.playerIsPlaying && this.isStreaming
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
@@ -393,6 +400,10 @@ export default {
|
||||
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: {
|
||||
@@ -434,6 +445,7 @@ export default {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
async play() {},
|
||||
async playEpisode() {
|
||||
await this.$hapticsImpact()
|
||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
@@ -472,29 +484,6 @@ export default {
|
||||
editClick() {
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
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() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||
@@ -62,7 +62,6 @@ export default {
|
||||
isProcessingReadUpdate: false,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false,
|
||||
@@ -82,9 +81,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
@@ -188,20 +184,21 @@ export default {
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
return this.collapsedSeries?.numBooks || 0
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
}
|
||||
return this.title
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.collapsedSeries) return `${this.booksInSeries} books in series`
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.collapsedSeries) return null
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
@@ -218,19 +215,19 @@ export default {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||
return !this.isSelectionMode && this.hasEbook
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
@@ -238,85 +235,13 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this._libraryItem.hasMissingParts
|
||||
},
|
||||
hasInvalidParts() {
|
||||
return this._libraryItem.hasInvalidParts
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) return 'Item has no media files'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
}
|
||||
if (this.hasInvalidParts) {
|
||||
if (this.hasMissingParts) txt += ' '
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
classes.push('border-2 border-yellow-400')
|
||||
}
|
||||
return classes
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
authorFontSize() {
|
||||
return 0.6 * this.sizeMultiplier
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
titleCleaned() {
|
||||
if (!this.title) return ''
|
||||
if (this.title.length > 60) {
|
||||
return this.title.slice(0, 57) + '...'
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
authorCleaned() {
|
||||
if (!this.author) return ''
|
||||
if (this.author.length > 30) {
|
||||
return this.author.slice(0, 27) + '...'
|
||||
}
|
||||
return this.author
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return false
|
||||
// var constants = this.$constants || this.$nuxt.$constants
|
||||
// return this.bookshelfView === constants.BookshelfView.TITLES
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
},
|
||||
coverWidth() {
|
||||
return 80 / this.bookCoverAspectRatio
|
||||
}
|
||||
@@ -341,7 +266,7 @@ export default {
|
||||
} else {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
if (router) {
|
||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||
if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
@@ -349,29 +274,6 @@ export default {
|
||||
editClick() {
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
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() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||
|
||||
@@ -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">local_offer</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ tag }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
tag: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tagSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
||||
<!-- list of server connection configs -->
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
|
||||
@@ -14,23 +15,21 @@
|
||||
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<!-- form to add a new server connection config -->
|
||||
<div v-else class="w-full">
|
||||
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<!-- server address input -->
|
||||
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
</div>
|
||||
<h2 class="text-lg leading-7 mb-2">Server address</h2>
|
||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
||||
<div class="flex justify-end items-center mt-6">
|
||||
<!-- <div class="relative flex">
|
||||
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
|
||||
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
|
||||
</div> -->
|
||||
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<template v-if="showAuth">
|
||||
<!-- username/password and auth methods -->
|
||||
<template v-else>
|
||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
</div>
|
||||
@@ -41,7 +40,7 @@
|
||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<form @submit.prevent="submitAuth" class="pt-3">
|
||||
<form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
|
||||
<ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" />
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
|
||||
|
||||
@@ -51,9 +50,12 @@
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="h-10 w-full" @click="clickLoginWithOpenId">{{ oauth.buttonText }}</ui-btn>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- auth error message -->
|
||||
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
|
||||
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||
<p class="text-error">{{ error }}</p>
|
||||
@@ -71,13 +73,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error"><strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.</p>
|
||||
|
||||
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { CapacitorHttp } from '@capacitor/core'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
// TODO: when backend ready. See validateLoginFormResponse()
|
||||
//const requiredServerVersion = '2.5.0'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
@@ -92,7 +101,14 @@ export default {
|
||||
password: null,
|
||||
error: null,
|
||||
showForm: false,
|
||||
showAddCustomHeaders: false
|
||||
showAddCustomHeaders: false,
|
||||
authMethods: [],
|
||||
oauth: {
|
||||
state: null,
|
||||
verifier: null,
|
||||
challenge: null,
|
||||
buttonText: 'Login with OpenID'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -103,21 +119,261 @@ export default {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
serverConnectionConfigs() {
|
||||
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
|
||||
return this.deviceData?.serverConnectionConfigs || []
|
||||
},
|
||||
lastServerConnectionConfigId() {
|
||||
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
|
||||
return this.deviceData?.lastServerConnectionConfigId || null
|
||||
},
|
||||
lastServerConnectionConfig() {
|
||||
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
||||
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
||||
},
|
||||
numCustomHeaders() {
|
||||
if (!this.serverConfig.customHeaders) return 0
|
||||
return Object.keys(this.serverConfig.customHeaders).length
|
||||
isLocalAuthEnabled() {
|
||||
return this.authMethods.includes('local') || !this.authMethods.length
|
||||
},
|
||||
isOpenIDAuthEnabled() {
|
||||
return this.authMethods.includes('openid')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Initiates the login process using OpenID via OAuth2.0.
|
||||
* 1. Verifying the server's address
|
||||
* 2. Calling oauthRequest() to obtain the special OpenID redirect URL
|
||||
* including a challenge and specying audiobookshelf://oauth as redirect URL
|
||||
* 3. Open this redirect URL in browser (which is a website of the SSO provider)
|
||||
*
|
||||
* When the browser is open, the following flow is expected:
|
||||
* a. The user authenticates and the provider redirects back to custom URL audiobookshelf://oauth
|
||||
* b. The app calls appUrlOpen() when `audiobookshelf://oauth` is called
|
||||
* b. appUrlOpen() handles the incoming URL and extracts the authorization code from GET parameter
|
||||
* c. oauthExchangeCodeForToken() exchanges the authorization code for an access token
|
||||
*
|
||||
*
|
||||
* @async
|
||||
* @throws Will log a console error if the browser fails to open the URL and display errors via this.error to the user.
|
||||
*/
|
||||
async clickLoginWithOpenId() {
|
||||
// oauth standard requires https explicitly
|
||||
if (!this.serverConfig.address.startsWith('https')) {
|
||||
console.warn(`[SSO] Oauth2 requires HTTPS`)
|
||||
this.$toast.error(`SSO: The URL to the server must be https:// secured`)
|
||||
return
|
||||
}
|
||||
|
||||
// First request that we want to do oauth/openid and get the URL which a browser window should open
|
||||
const redirectUrl = await this.oauthRequest(this.serverConfig.address)
|
||||
if (!redirectUrl) {
|
||||
// error message handled by oauthRequest
|
||||
return
|
||||
}
|
||||
|
||||
// Actually we should be able to use the redirectUrl directly for Browser.open below
|
||||
// However it seems that when directly using it there is a malformation and leads to the error
|
||||
// Unhandled Promise Rejection: DataCloneError: The object can not be cloned.
|
||||
// (On calling Browser.open)
|
||||
// Which is hard to debug
|
||||
// So we simply extract the important elements and build the required URL ourselves
|
||||
// which also has the advantage that we can replace the callbackurl with the app url
|
||||
|
||||
const client_id = redirectUrl.searchParams.get('client_id')
|
||||
const scope = redirectUrl.searchParams.get('scope')
|
||||
const state = redirectUrl.searchParams.get('state')
|
||||
|
||||
if (!client_id || !scope || !state) {
|
||||
console.warn(`[SSO] Invalid OpenID URL - client_id scope or state missing: ${redirectUrl}`)
|
||||
this.$toast.error(`SSO: Invalid answer`)
|
||||
return
|
||||
}
|
||||
|
||||
if (redirectUrl.protocol !== 'https:') {
|
||||
console.warn(`[SSO] Insecure Redirection by SSO provider: ${redirectUrl.protocol} is not allowed. Use HTTPS`)
|
||||
this.$toast.error(`SSO: The SSO provider must return a HTTPS secured URL`)
|
||||
return
|
||||
}
|
||||
|
||||
// We need to verify if the state is the same later
|
||||
this.oauth.state = state
|
||||
|
||||
const host = `https://${redirectUrl.host}`
|
||||
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
|
||||
|
||||
// example url for authentik
|
||||
// const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..."
|
||||
|
||||
// Open the browser. The browser/identity provider in turn will redirect to an in-app link supplementing a code
|
||||
try {
|
||||
await Browser.open({ url: buildUrl })
|
||||
} catch (error) {
|
||||
console.error('Error opening browser', error)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Requests the OAuth/OpenID URL from the backend server to open in browser
|
||||
*
|
||||
* @async
|
||||
* @param {string} url - The base URL of the server to append the OAuth request parameters to.
|
||||
* @return {Promise<URL|null>} OAuth URL which should be opened in a browser
|
||||
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||
*/
|
||||
async oauthRequest(url) {
|
||||
// Generate oauth2 PKCE challenge
|
||||
// In accordance to RFC 7636 Section 4
|
||||
function base64URLEncode(arrayBuffer) {
|
||||
let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
|
||||
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
async function sha256(plain) {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(plain)
|
||||
return await window.crypto.subtle.digest('SHA-256', data)
|
||||
}
|
||||
|
||||
function generateRandomString() {
|
||||
var array = new Uint32Array(42)
|
||||
window.crypto.getRandomValues(array)
|
||||
return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join('') // hex
|
||||
}
|
||||
|
||||
const verifier = generateRandomString()
|
||||
|
||||
const challenge = base64URLEncode(await sha256(verifier))
|
||||
|
||||
this.oauth.verifier = verifier
|
||||
this.oauth.challenge = challenge
|
||||
|
||||
// set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken
|
||||
const backendEndpoint = `${url}auth/openid?code_challenge=${challenge}&code_challenge_method=S256&isRest=true`
|
||||
|
||||
try {
|
||||
const response = await CapacitorHttp.get({
|
||||
url: backendEndpoint,
|
||||
disableRedirects: true,
|
||||
webFetchExtra: {
|
||||
redirect: 'manual'
|
||||
}
|
||||
})
|
||||
|
||||
// Every kind of redirection is allowed [RFC6749 - 1.7]
|
||||
if (!(response.status >= 300 && response.status < 400)) {
|
||||
throw new Error(`Unexpected response from server: ${response.status}`)
|
||||
}
|
||||
|
||||
// Depending on iOS or Android, it can be location or Location...
|
||||
const locationHeader = response.headers[Object.keys(response.headers).find((key) => key.toLowerCase() === 'location')]
|
||||
if (!locationHeader) {
|
||||
throw new Error(`No location header in SSO answer`)
|
||||
}
|
||||
|
||||
const url = new URL(locationHeader)
|
||||
return url
|
||||
} catch (error) {
|
||||
console.error(`[SSO] ${error.message}`)
|
||||
this.$toast.error(`SSO Error: ${error.message}`)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles the callback received from the OAuth/OpenID provider.
|
||||
*
|
||||
* @async
|
||||
* @function appUrlOpen
|
||||
* @param {string} url - The callback URL received from the OAuth/OpenID provider.
|
||||
* @throws Logs a warning and displays a toast notification if the URL is invalid or the state doesn't match.
|
||||
*/
|
||||
async appUrlOpen(url) {
|
||||
if (!url) return
|
||||
|
||||
// Handle the OAuth callback
|
||||
const urlObj = new URL(url)
|
||||
|
||||
// audiobookshelf://oauth?code...
|
||||
// urlObj.hostname for iOS and urlObj.pathname for android
|
||||
if (url.startsWith('audiobookshelf://oauth')) {
|
||||
// Extract possible errors thrown by the SSO provider
|
||||
const authError = urlObj.searchParams.get('error')
|
||||
if (authError) {
|
||||
console.warn(`[SSO] Received the following error: ${authError}`)
|
||||
this.$toast.error(`SSO: Received the following error: ${authError}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract oauth2 code to be exchanged for a token
|
||||
const authCode = urlObj.searchParams.get('code')
|
||||
// Extract the state variable
|
||||
const state = urlObj.searchParams.get('state')
|
||||
|
||||
if (this.oauth.state !== state) {
|
||||
console.warn(`[SSO] Wrong state returned by SSO Provider`)
|
||||
this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the state variable from the component config
|
||||
this.oauth.state = null
|
||||
|
||||
if (authCode) {
|
||||
await this.oauthExchangeCodeForToken(authCode, state)
|
||||
} else {
|
||||
console.warn(`[SSO] No code received`)
|
||||
this.$toast.error(`SSO: The response from the SSO Provider did not include a code (authentication error?)`)
|
||||
}
|
||||
} else {
|
||||
console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Exchanges an oauth2 authorization code for a JWT token.
|
||||
* And uses that token to finalise the log in process using authenticateToken()
|
||||
*
|
||||
* @async
|
||||
* @function oauthExchangeCodeForToken
|
||||
* @param {string} code - The authorization code provided by the OpenID provider.
|
||||
* @param {string} state - The state value used to associate a client session with an ID token.
|
||||
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||
*/
|
||||
async oauthExchangeCodeForToken(code, state) {
|
||||
// We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info
|
||||
const backendEndpoint = `${this.serverConfig.address}/auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}`
|
||||
|
||||
try {
|
||||
// We can close the browser at this point (does not work on Android)
|
||||
if (this.$platform === 'ios' || this.$platform === 'web') {
|
||||
await Browser.close()
|
||||
}
|
||||
} catch (error) {} // No Error handling needed
|
||||
|
||||
try {
|
||||
const response = await CapacitorHttp.get({
|
||||
url: backendEndpoint
|
||||
})
|
||||
|
||||
if (!response.data || !response.data.user || !response.data.user.token) {
|
||||
throw new Error('Token data is missing in the response.')
|
||||
}
|
||||
|
||||
this.serverConfig.token = response.data.user.token
|
||||
const payload = await this.authenticateToken()
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Authentication failed with the provided token.')
|
||||
}
|
||||
|
||||
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
|
||||
if (duplicateConfig) {
|
||||
throw new Error('Config already exists for this address and username.')
|
||||
}
|
||||
|
||||
this.setUserAndConnection(payload)
|
||||
} catch (error) {
|
||||
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
||||
this.$toast.error(`SSO error: ${error.message || error}`)
|
||||
} finally {
|
||||
// We don't need the oauth verifier any more
|
||||
this.oauth.verifier = null
|
||||
this.oauth.challenge = null
|
||||
}
|
||||
},
|
||||
addCustomHeaders() {
|
||||
this.showAddCustomHeaders = true
|
||||
},
|
||||
@@ -206,10 +462,18 @@ export default {
|
||||
this.error = null
|
||||
this.showAuth = false
|
||||
},
|
||||
validateServerUrl(url) {
|
||||
/**
|
||||
* Validates a URL and reconstructs it with an optional protocol override.
|
||||
* If the URL is invalid, null is returned.
|
||||
*
|
||||
* @param {string} url - The URL to validate.
|
||||
* @param {string|null} [protocolOverride=null] - (Optional) Protocol to override the URL's original protocol.
|
||||
* @returns {string|null} The validated URL with the original or overridden protocol, or null if invalid.
|
||||
*/
|
||||
validateServerUrl(url, protocolOverride = null) {
|
||||
try {
|
||||
var urlObject = new URL(url)
|
||||
var address = `${urlObject.protocol}//${urlObject.hostname}`
|
||||
var address = `${protocolOverride ? protocolOverride : urlObject.protocol}//${urlObject.hostname}`
|
||||
if (urlObject.port) address += ':' + urlObject.port
|
||||
return address
|
||||
} catch (error) {
|
||||
@@ -217,27 +481,94 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
pingServerAddress(address, customHeaders) {
|
||||
const options = { timeout: 3000 }
|
||||
if (customHeaders) {
|
||||
options.headers = customHeaders
|
||||
/**
|
||||
* Sends a GET request to the specified URL with the provided headers and timeout.
|
||||
* If the response is successful (HTTP 200), the response object is returned.
|
||||
* Otherwise, throws an error object containing code.
|
||||
* code can be either a number, which is then a HTTP status code or
|
||||
* a string, which is then a keyword like NSURLErrorBadURL when the TCP connection could not be established.
|
||||
* When code is a string, error.message contains the human readable error by the OS or
|
||||
* the http body of the non-200 answer.
|
||||
*
|
||||
* @async
|
||||
* @param {string} url - The URL to which the GET request will be sent.
|
||||
* @param {Object} headers - HTTP headers to be included in the request.
|
||||
* @param {number} [connectTimeout=6000] - Timeout for the request in milliseconds.
|
||||
* @returns {Promise<HttpResponse>} The HTTP response object if the request is successful.
|
||||
* @throws {Error} An error with 'code' property set to the HTTP status code if the response is not successful.
|
||||
* @throws {Error} An error with 'code' property set to the error code if the request fails.
|
||||
*/
|
||||
async getRequest(url, headers, connectTimeout = 6000) {
|
||||
const options = {
|
||||
url,
|
||||
headers,
|
||||
connectTimeout
|
||||
}
|
||||
return this.$axios
|
||||
.$get(`${address}/ping`, options)
|
||||
.then((data) => data.success)
|
||||
try {
|
||||
const response = await CapacitorHttp.get(options)
|
||||
console.log('[ServerConnectForm] GET request response', response)
|
||||
if (response.status == 200) {
|
||||
return response
|
||||
} else {
|
||||
// Put the HTTP error code inside the cause
|
||||
let errorObj = new Error(response.data)
|
||||
errorObj.code = response.status
|
||||
throw errorObj
|
||||
}
|
||||
} catch (error) {
|
||||
// Put the error name inside the cause (a string)
|
||||
let errorObj = new Error(error.message)
|
||||
errorObj.code = error.code
|
||||
throw errorObj
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get request to server /status api endpoint
|
||||
*
|
||||
* @param {string} address
|
||||
* @returns {Promise<HttpResponse>}
|
||||
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||
*/
|
||||
async getServerAddressStatus(address) {
|
||||
return this.getRequest(`${address}/status`)
|
||||
},
|
||||
pingServerAddress(address, customHeaders) {
|
||||
return this.getRequest(`${address}/ping`, customHeaders)
|
||||
.then((response) => {
|
||||
return response.data.success
|
||||
})
|
||||
.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'
|
||||
if (typeof errorMsg === 'string') {
|
||||
this.error += ` (${errorMsg})`
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
},
|
||||
requestServerLogin() {
|
||||
const options = {}
|
||||
if (this.serverConfig.customHeaders) {
|
||||
options.headers = this.serverConfig.customHeaders
|
||||
}
|
||||
return this.$axios
|
||||
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
|
||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, this.serverConfig.customHeaders, 20000)
|
||||
.then((data) => {
|
||||
if (!data.user) {
|
||||
console.error(data.error)
|
||||
@@ -248,30 +579,186 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Server auth failed', error)
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.error = errorMsg
|
||||
const errorMsg = error.message || error
|
||||
this.error = 'Failed to login'
|
||||
if (typeof errorMsg === 'string') {
|
||||
this.error += ` (${errorMsg})`
|
||||
}
|
||||
return false
|
||||
})
|
||||
},
|
||||
async submit() {
|
||||
if (!this.networkConnected) return
|
||||
if (!this.serverConfig.address) return
|
||||
if (!this.serverConfig.address.startsWith('http')) {
|
||||
this.serverConfig.address = 'http://' + this.serverConfig.address
|
||||
}
|
||||
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
|
||||
if (!validServerAddress) {
|
||||
this.error = 'Invalid server address'
|
||||
return
|
||||
}
|
||||
|
||||
this.serverConfig.address = validServerAddress
|
||||
const initialAddress = this.serverConfig.address
|
||||
// Did the user specify a protocol?
|
||||
const protocolProvided = initialAddress.startsWith('http://') || initialAddress.startsWith('https://')
|
||||
// Add https:// if not provided
|
||||
this.serverConfig.address = this.prependProtocolIfNeeded(initialAddress)
|
||||
|
||||
this.processing = true
|
||||
this.error = null
|
||||
this.authMethods = []
|
||||
|
||||
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
|
||||
this.processing = false
|
||||
if (success) this.showAuth = true
|
||||
try {
|
||||
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
||||
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
||||
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
||||
this.showAuth = true
|
||||
this.authMethods = statusData.data.authMethods || []
|
||||
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
||||
|
||||
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
||||
this.clickLoginWithOpenId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleLoginFormError(error)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
/** Validates the login form response from the server.
|
||||
*
|
||||
* Ensure the request has not been redirected to an unexpected hostname and check if it is Audiobookshelf
|
||||
*
|
||||
* @param {object} statusData - The data received from the server's response, including data and url.
|
||||
* @param {string} initialAddressWithProtocol - The initial server address including the protocol used for the request.
|
||||
* @param {boolean} protocolProvided - Indicates whether the protocol was explicitly provided in the initial address.
|
||||
*
|
||||
* @returns {boolean} - Returns `true` if the response is valid, otherwise `false` and sets this.error.
|
||||
*/
|
||||
validateLoginFormResponse(statusData, initialAddressWithProtocol, protocolProvided) {
|
||||
// We have a 200 status code at this point
|
||||
|
||||
// Check if we got redirected to a different hostname, we don't allow this
|
||||
const initialAddressUrl = new URL(initialAddressWithProtocol)
|
||||
const currentAddressUrl = new URL(statusData.url)
|
||||
if (initialAddressUrl.hostname !== currentAddressUrl.hostname) {
|
||||
this.error = `Server redirected somewhere else (to ${currentAddressUrl.hostname})`
|
||||
console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`)
|
||||
return false
|
||||
} // We don't allow a redirection back from https to http if the user used https:// explicitly
|
||||
else if (protocolProvided && initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') {
|
||||
this.error = `You specified https:// but the Server redirected back to plain http`
|
||||
console.error(`[ServerConnectForm] User specified https:// but server redirected to http`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check content of response now
|
||||
if (!statusData || !statusData.data || Object.keys(statusData).length === 0) {
|
||||
this.error = 'Response from server was empty' // Usually some kind of config error on server side
|
||||
console.error('[ServerConnectForm] Received empty response')
|
||||
return false
|
||||
} else if (!('isInit' in statusData.data) || !('language' in statusData.data)) {
|
||||
this.error = 'This does not seem to be a Audiobookshelf server'
|
||||
console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||
return false
|
||||
// TODO: delete the if above and comment the ones below out, as soon as the backend is ready to introduce a version check
|
||||
// } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') {
|
||||
// this.error = 'This does not seem to be a Audiobookshelf server'
|
||||
// console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||
// return false
|
||||
// } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) {
|
||||
// this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`
|
||||
// console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion)
|
||||
// return false
|
||||
} else if (!statusData.data.isInit) {
|
||||
this.error = 'Server is not initialized'
|
||||
return false
|
||||
}
|
||||
|
||||
// If we got redirected from http to https, we allow this
|
||||
// Also there is the possibility that https was tried (with protocolProvided false) but only http was successfull
|
||||
// So set the correct protocol for the config
|
||||
const configUrl = new URL(this.serverConfig.address)
|
||||
configUrl.protocol = currentAddressUrl.protocol
|
||||
// Remove trailing slash
|
||||
this.serverConfig.address = configUrl.toString().replace(/\/$/, '')
|
||||
|
||||
return true
|
||||
},
|
||||
/**
|
||||
* Handles errors received during the login form process, providing user-friendly error messages.
|
||||
*
|
||||
* @param {Object} error - The error object received from a failed login attempt.
|
||||
*/
|
||||
handleLoginFormError(error) {
|
||||
console.error('[ServerConnectForm] Received invalid status', error)
|
||||
|
||||
if (error.code === 404) {
|
||||
this.error = `This does not seem to be an Audiobookshelf server. (Error: 404 querying /status)`
|
||||
} else if (typeof error.code === 'number') {
|
||||
// Error with HTTP Code
|
||||
this.error = `Failed to retrieve status of server: ${error.code}`
|
||||
} else {
|
||||
// error is usually a meaningful error like "Server timed out"
|
||||
this.error = `Failed to contact server. (${error})`
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Attempts to retrieve the server address status for the given URL.
|
||||
* If the initial attempt fails, it retries with HTTP if allowed.
|
||||
*
|
||||
* @param {string} address - The URL address to validate and check.
|
||||
* @param {boolean} shouldRetryWithHttp - Flag to indicate if the function should retry with HTTP on failure.
|
||||
* @returns {Promise<HttpResponse>}
|
||||
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||
* @throws Will throw an error if the URL has a wrong format or if both HTTPS and HTTP (if retried) requests fail.
|
||||
*/
|
||||
async tryServerUrl(address, shouldRetryWithHttp) {
|
||||
const validatedUrl = this.validateServerUrl(address)
|
||||
if (!validatedUrl) {
|
||||
throw new Error('URL has wrong format')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.getServerAddressStatus(validatedUrl)
|
||||
} catch (error) {
|
||||
// We only retry when the user did not specify a protocol
|
||||
// Also for security reasons, we only retry when the https request did not
|
||||
// return a http status code (so only retry when the TCP connection could not be established)
|
||||
if (shouldRetryWithHttp && typeof error.code !== 'number') {
|
||||
console.log('[ServerConnectForm] https failed, trying to connect with http...')
|
||||
const validatedHttpUrl = this.validateServerUrl(address, 'http:')
|
||||
if (validatedHttpUrl) {
|
||||
return await this.getServerAddressStatus(validatedHttpUrl)
|
||||
}
|
||||
// else if validatedHttpUrl is false return the original error below
|
||||
}
|
||||
// rethrow original error
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Ensures that a protocol is prepended to the given address if it does not already start with http:// or https://.
|
||||
*
|
||||
* @param {string} address - The server address that may or may not have a protocol.
|
||||
* @returns {string} The address with a protocol prepended if it was missing.
|
||||
*/
|
||||
prependProtocolIfNeeded(address) {
|
||||
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
|
||||
},
|
||||
/**
|
||||
* Compares two semantic versioning strings to determine if the current version meets
|
||||
* or exceeds the minimum version requirement.
|
||||
*
|
||||
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
|
||||
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
|
||||
* @returns {boolean} - Returns true if the current version is greater than or equal
|
||||
* to the minimum version, false otherwise.
|
||||
*/
|
||||
isValidVersion(currentVersion, minVersion) {
|
||||
const currentParts = currentVersion.split('.').map(Number)
|
||||
const minParts = minVersion.split('.').map(Number)
|
||||
|
||||
for (let i = 0; i < minParts.length; i++) {
|
||||
if (currentParts[i] > minParts[i]) return true
|
||||
if (currentParts[i] < minParts[i]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
async submitAuth() {
|
||||
if (!this.networkConnected) return
|
||||
@@ -295,12 +782,13 @@ export default {
|
||||
this.setUserAndConnection(payload)
|
||||
}
|
||||
},
|
||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) {
|
||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||
if (!user) return
|
||||
|
||||
console.log('Successfully logged in', JSON.stringify(user))
|
||||
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
|
||||
// Set library - Use last library if set and available fallback to default user library
|
||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||
@@ -312,6 +800,7 @@ export default {
|
||||
|
||||
this.serverConfig.userId = user.id
|
||||
this.serverConfig.token = user.token
|
||||
this.serverConfig.username = user.username
|
||||
|
||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||
|
||||
@@ -330,16 +819,23 @@ export default {
|
||||
|
||||
this.error = null
|
||||
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)
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.error = errorMsg
|
||||
|
||||
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
||||
console.error('[ServerConnectForm] Server auth failed', error)
|
||||
const errorMsg = error.message || error
|
||||
this.error = 'Failed to authorize'
|
||||
if (typeof errorMsg === 'string') {
|
||||
this.error += ` (${errorMsg})`
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
console.log('[ServerConnectForm] authRes=', authRes)
|
||||
|
||||
this.processing = false
|
||||
return authRes
|
||||
},
|
||||
async init() {
|
||||
init() {
|
||||
if (this.lastServerConnectionConfig) {
|
||||
this.connectToServer(this.lastServerConnectionConfig)
|
||||
} else {
|
||||
@@ -348,7 +844,11 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('url-open', this.appUrlOpen)
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('url-open', this.appUrlOpen)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -51,9 +51,6 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -140,8 +140,8 @@ export default {
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
||||
this._processing = true
|
||||
this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
this.$nativeHttp
|
||||
.post('/api/podcasts', podcastPayload)
|
||||
.then((libraryItem) => {
|
||||
this._processing = false
|
||||
this.$toast.success('Podcast added')
|
||||
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
iconPack: 'material-icons',
|
||||
icon: 'podcasts',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Search'
|
||||
text: 'Add'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Bookmarks</p>
|
||||
@@ -102,44 +102,42 @@ export default {
|
||||
})
|
||||
if (!value) return
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||
this.$nativeHttp
|
||||
.delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark removed')
|
||||
this.$store.commit('user/deleteBookmark', { libraryItemId: this.libraryItemId, time: bm.time })
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to remove bookmark`)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
async clickBookmark(bm) {
|
||||
await this.$hapticsImpact()
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
var bookmark = { ...updatedBookmark }
|
||||
this.$axios
|
||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark updated')
|
||||
this.$nativeHttp
|
||||
.patch(`/api/me/item/${this.libraryItemId}/bookmark`, updatedBookmark)
|
||||
.then((bookmark) => {
|
||||
this.$store.commit('user/updateBookmark', bookmark)
|
||||
this.showBookmarkTitleInput = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to update bookmark`)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
if (!this.newBookmarkTitle) {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
}
|
||||
var bookmark = {
|
||||
const bookmark = {
|
||||
title: this.newBookmarkTitle,
|
||||
time: Math.floor(this.currentTime)
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
this.$nativeHttp
|
||||
.post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark added')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="width" height="100%">
|
||||
<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-10 left-4 z-40 pt-1 pb-1.5" style="max-width: 80%">
|
||||
<p class="text-white text-xl truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div>
|
||||
<modals-dialog v-model="show" :items="moreMenuItems" @action="moreMenuAction" />
|
||||
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
|
||||
<modals-dialog v-model="showSendEbookDevicesModal" title="Select a device" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem } from '@/plugins/capacitor'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
rssFeed: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
hideRssFeedOption: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showDetailsModal: false,
|
||||
showSendEbookDevicesModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
moreMenuItems() {
|
||||
const items = []
|
||||
|
||||
// TODO: Implement on iOS
|
||||
if (this.$platform !== 'ios' && !this.isPodcast) {
|
||||
items.push({
|
||||
text: 'History',
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isPodcast || this.episode) {
|
||||
if (!this.userIsFinished) {
|
||||
items.push({
|
||||
text: 'Mark as Finished',
|
||||
value: 'markFinished',
|
||||
icon: 'beenhere'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.progressPercent > 0) {
|
||||
items.push({
|
||||
text: 'Discard Progress',
|
||||
value: 'discardProgress',
|
||||
icon: 'backspace'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ((!this.isPodcast && this.serverLibraryItemId) || (this.episode && this.serverEpisodeId)) {
|
||||
items.push({
|
||||
text: 'Add to Playlist',
|
||||
value: 'playlist',
|
||||
icon: 'playlist_add'
|
||||
})
|
||||
|
||||
if (this.ereaderDeviceItems.length) {
|
||||
items.push({
|
||||
text: 'Send ebook to device',
|
||||
value: 'sendEbook',
|
||||
icon: 'send'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.showRSSFeedOption) {
|
||||
items.push({
|
||||
text: this.rssFeed ? 'RSS Feed' : 'Open RSS Feed',
|
||||
value: 'rssFeed',
|
||||
icon: 'rss_feed'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.localLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Manage Local Files',
|
||||
value: 'manageLocal',
|
||||
icon: 'folder'
|
||||
})
|
||||
|
||||
if (!this.isPodcast) {
|
||||
items.push({
|
||||
text: 'Delete Local Item',
|
||||
value: 'deleteLocal',
|
||||
icon: 'delete'
|
||||
})
|
||||
} else if (this.localEpisodeId) {
|
||||
items.push({
|
||||
text: 'Delete Local Episode',
|
||||
value: 'deleteLocalEpisode',
|
||||
icon: 'delete'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.episode) {
|
||||
items.push({
|
||||
text: 'More Info',
|
||||
value: 'details',
|
||||
icon: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
ereaderDeviceItems() {
|
||||
if (!this.ebookFile || !this.$store.state.libraries.ereaderDevices?.length) return []
|
||||
return this.$store.state.libraries.ereaderDevices.map((d) => {
|
||||
return {
|
||||
text: d.name,
|
||||
value: d.name
|
||||
}
|
||||
})
|
||||
},
|
||||
isConnectedToServer() {
|
||||
if (!this.isLocal) return true
|
||||
if (!this.libraryItem?.serverAddress) return false
|
||||
return this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress
|
||||
},
|
||||
isLocal() {
|
||||
return !!this.libraryItem?.isLocal
|
||||
},
|
||||
localLibraryItem() {
|
||||
if (this.isLocal) return this.libraryItem
|
||||
return this.libraryItem?.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemId() {
|
||||
return this.localLibraryItem?.id || null
|
||||
},
|
||||
serverLibraryItemId() {
|
||||
if (!this.isLocal) return this.libraryItem?.id
|
||||
if (this.isConnectedToServer) {
|
||||
return this.libraryItem.libraryItemId
|
||||
}
|
||||
return null
|
||||
},
|
||||
localEpisode() {
|
||||
if (this.isLocal) return this.episode
|
||||
return this.episode.localEpisode
|
||||
},
|
||||
localEpisodeId() {
|
||||
return this.localEpisode?.id || null
|
||||
},
|
||||
serverEpisodeId() {
|
||||
if (!this.isLocal) return this.episode?.id
|
||||
if (this.isConnectedToServer) {
|
||||
return this.episode.serverEpisodeId
|
||||
}
|
||||
return null
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem?.mediaType
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
localItemProgress() {
|
||||
if (this.isPodcast) {
|
||||
if (!this.localEpisodeId) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisodeId)
|
||||
}
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||
},
|
||||
serverItemProgress() {
|
||||
if (this.isPodcast) {
|
||||
if (!this.serverEpisodeId) return null
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
|
||||
}
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||
},
|
||||
userItemProgress() {
|
||||
if (this.isLocal) return this.localItemProgress
|
||||
return this.serverItemProgress
|
||||
},
|
||||
userIsFinished() {
|
||||
return !!this.userItemProgress?.isFinished
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userItemProgress || this.userItemProgress.progress) return false
|
||||
return this.userItemProgress.ebookProgress > 0
|
||||
},
|
||||
progressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userItemProgress.ebookProgress), 0)
|
||||
return Math.max(Math.min(1, this.userItemProgress?.progress || 0), 0)
|
||||
},
|
||||
showRSSFeedOption() {
|
||||
if (this.hideRssFeedOption) return false
|
||||
if (!this.serverLibraryItemId) return false
|
||||
if (!this.rssFeed && !this.episodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
||||
|
||||
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||
return this.userIsAdminOrUp || this.rssFeed
|
||||
},
|
||||
mediaId() {
|
||||
if (this.isPodcast) return null
|
||||
return this.serverLibraryItemId || this.localLibraryItemId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
moreMenuAction(action) {
|
||||
this.show = false
|
||||
if (action === 'manageLocal') {
|
||||
this.$nextTick(() => {
|
||||
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
|
||||
})
|
||||
} else if (action === 'details') {
|
||||
this.showDetailsModal = true
|
||||
} else if (action === 'playlist') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'markFinished') {
|
||||
if (this.episode) this.toggleEpisodeFinished()
|
||||
else this.toggleFinished()
|
||||
} else if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
} else if (action === 'discardProgress') {
|
||||
this.clearProgressClick()
|
||||
} else if (action === 'deleteLocal') {
|
||||
this.deleteLocalItem()
|
||||
} else if (action === 'deleteLocalEpisode') {
|
||||
this.deleteLocalEpisode()
|
||||
} else if (action === 'rssFeed') {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'sendEbook') {
|
||||
this.showSendEbookDevicesModal = true
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
// Show confirm if item has progress since it will reset
|
||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to mark this item as Finished?'
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
if (this.isLocal) {
|
||||
const isFinished = !this.userIsFinished
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
},
|
||||
async toggleEpisodeFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
const isFinished = !this.userIsFinished
|
||||
const localLibraryItemId = this.localLibraryItemId
|
||||
const localEpisodeId = this.localEpisodeId
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}/${this.serverEpisodeId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
},
|
||||
async clearProgressClick() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to reset your progress?'
|
||||
})
|
||||
if (value) {
|
||||
this.$emit('update:processing', true)
|
||||
const serverMediaProgressId = this.serverItemProgress?.id
|
||||
if (this.localItemProgress) {
|
||||
await this.$db.removeLocalMediaProgress(this.localItemProgress.id)
|
||||
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localItemProgress.id)
|
||||
}
|
||||
|
||||
if (serverMediaProgressId) {
|
||||
await this.$nativeHttp
|
||||
.delete(`/api/me/progress/${serverMediaProgressId}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
this.$store.commit('user/removeMediaProgress', serverMediaProgressId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Progress reset failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
this.$emit('update:processing', false)
|
||||
}
|
||||
},
|
||||
async deleteLocalEpisode() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const localEpisodeAudioTrack = this.localEpisode.audioTrack
|
||||
const localFile = this.localLibraryItem.localFiles.find((lf) => lf.id === localEpisodeAudioTrack.localFileId)
|
||||
if (!localFile) {
|
||||
this.$toast.error('Audio track does not have matching local file..')
|
||||
return
|
||||
}
|
||||
|
||||
let confirmMessage = `Remove local episode "${localFile.basePath}" from your device?`
|
||||
if (this.serverLibraryItemId) {
|
||||
confirmMessage += ' The file on the server will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
const res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItemId, trackLocalFileId: localFile.id, trackContentUrl: localEpisodeAudioTrack.contentUrl })
|
||||
if (res?.id) {
|
||||
this.$toast.success('Deleted episode successfully')
|
||||
if (this.isLocal) {
|
||||
// If this is local episode then redirect to server episode when available
|
||||
if (this.serverEpisodeId) {
|
||||
this.$router.replace(`/item/${this.serverLibraryItemId}/${this.serverEpisodeId}`)
|
||||
} else {
|
||||
this.$router.replace(`/item/${this.localLibraryItemId}`)
|
||||
}
|
||||
} else {
|
||||
// Update local library item and local episode
|
||||
this.libraryItem.localLibraryItem = res
|
||||
this.$delete(this.episode, 'localEpisode')
|
||||
}
|
||||
} else this.$toast.error('Failed to delete')
|
||||
}
|
||||
},
|
||||
async deleteLocalItem() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
let confirmMessage = 'Remove local files of this item from your device?'
|
||||
if (this.serverLibraryItemId) {
|
||||
confirmMessage += ' The files on the server and your progress will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||
if (res?.success) {
|
||||
this.$toast.success('Deleted successfully')
|
||||
if (this.isLocal) {
|
||||
// If local then redirect to server version when available
|
||||
if (this.serverLibraryItemId) {
|
||||
this.$router.replace(`/item/${this.serverLibraryItemId}`)
|
||||
} else {
|
||||
this.$router.replace('/bookshelf')
|
||||
}
|
||||
} else {
|
||||
// Remove localLibraryItem
|
||||
this.$delete(this.libraryItem, 'localLibraryItem')
|
||||
}
|
||||
} else this.$toast.error('Failed to delete')
|
||||
}
|
||||
},
|
||||
clickRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.serverLibraryItemId,
|
||||
name: this.title,
|
||||
type: 'item',
|
||||
feed: this.rssFeed,
|
||||
hasEpisodesWithoutPubDate: this.episodes.some((ep) => !ep.pubDate)
|
||||
})
|
||||
},
|
||||
sendEbookToDeviceAction(deviceName) {
|
||||
this.showSendEbookDevicesModal = false
|
||||
|
||||
const payload = {
|
||||
libraryItemId: this.serverLibraryItemId,
|
||||
deviceName
|
||||
}
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.post(`/api/emails/send-ebook-to-device`, payload)
|
||||
.then(() => {
|
||||
this.$toast.success('Ebook sent successfully')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send ebook to device', error)
|
||||
this.$toast.error('Failed to send ebook to device')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -96,8 +96,8 @@ export default {
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||
this.$nativeHttp
|
||||
.post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Started downloading episodes on server')
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<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">
|
||||
<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">
|
||||
<p class="pl-2 pr-2 truncate text-sm">{{ bookmark.title }}</p>
|
||||
<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">
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<div class="flex items-center mb-0.5">
|
||||
<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 class="h-full flex items-center w-14 justify-end">
|
||||
<span class="font-mono text-xs text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
|
||||
</div>
|
||||
<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 class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
|
||||
<span class="material-icons text-2xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-2xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -109,8 +109,8 @@ export default {
|
||||
},
|
||||
loadPlaylists() {
|
||||
this.loading = true
|
||||
this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
|
||||
this.$nativeHttp
|
||||
.get(`/api/libraries/${this.currentLibraryId}/playlists`)
|
||||
.then((data) => {
|
||||
this.playlists = data.results || []
|
||||
})
|
||||
@@ -135,8 +135,8 @@ export default {
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
this.$axios
|
||||
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||
this.$nativeHttp
|
||||
.post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items removed from playlist`, updatedPlaylist)
|
||||
})
|
||||
@@ -153,8 +153,8 @@ export default {
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
this.$axios
|
||||
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
||||
this.$nativeHttp
|
||||
.post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items added to playlist`, updatedPlaylist)
|
||||
})
|
||||
@@ -184,8 +184,8 @@ export default {
|
||||
name: this.newPlaylistName
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$post('/api/playlists', newPlaylist)
|
||||
this.$nativeHttp
|
||||
.post('/api/playlists', newPlaylist)
|
||||
.then((data) => {
|
||||
console.log('New playlist created', data)
|
||||
this.newPlaylistName = ''
|
||||
|
||||
@@ -145,8 +145,8 @@ export default {
|
||||
}
|
||||
|
||||
console.log('Payload', payload)
|
||||
this.$axios
|
||||
.$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
|
||||
this.$nativeHttp
|
||||
.post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
|
||||
.then((data) => {
|
||||
console.log('Opened RSS Feed', data)
|
||||
this.currentFeed = data.feed
|
||||
@@ -163,8 +163,8 @@ export default {
|
||||
},
|
||||
closeFeed() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/feeds/${this.currentFeed.id}/close`)
|
||||
this.$nativeHttp
|
||||
.post(`/api/feeds/${this.currentFeed.id}/close`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
|
||||
this.show = false
|
||||
|
||||
@@ -183,7 +183,7 @@ export default {
|
||||
|
||||
// Update server item
|
||||
if (this.serverLibraryItemId) {
|
||||
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||
this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||
console.error('ComicReader.updateProgress failed:', error)
|
||||
})
|
||||
}
|
||||
@@ -335,8 +335,8 @@ export default {
|
||||
},
|
||||
parseImageFilename(filename) {
|
||||
var basename = Path.basename(filename, Path.extname(filename))
|
||||
var numbersinpath = basename.match(/\d{1,5}/g)
|
||||
if (!numbersinpath || !numbersinpath.length) {
|
||||
var numbersinpath = basename.match(/\d+/g)
|
||||
if (!numbersinpath?.length) {
|
||||
return {
|
||||
index: -1,
|
||||
filename
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div id="viewer" class="h-full w-full"></div>
|
||||
|
||||
<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>
|
||||
@@ -29,6 +30,10 @@ export default {
|
||||
/** @type {ePub.Rendition} */
|
||||
rendition: null,
|
||||
progress: 0,
|
||||
totalLocations: 0,
|
||||
currentLocationNum: 0,
|
||||
currentLocationCfi: null,
|
||||
inittingDisplay: true,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
@@ -103,15 +108,11 @@ export default {
|
||||
const fontColor = isDark ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||
|
||||
const lineSpacing = this.ereaderSettings.lineSpacing / 100
|
||||
|
||||
const fontScale = this.ereaderSettings.fontScale / 100
|
||||
|
||||
return {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': lineSpacing * fontScale + 'rem!important'
|
||||
'line-height': this.ereaderSettings.lineSpacing + '%!important'
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
@@ -171,7 +172,7 @@ export default {
|
||||
|
||||
// Update server item
|
||||
if (this.serverLibraryItemId) {
|
||||
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||
this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||
console.error('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
}
|
||||
@@ -263,9 +264,20 @@ export default {
|
||||
},
|
||||
/** @param {string} location - CFI of the new location */
|
||||
relocated(location) {
|
||||
if (this.savedEbookLocation === location.start.cfi) {
|
||||
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({
|
||||
@@ -284,7 +296,7 @@ export default {
|
||||
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
console.log('initEpub', reader.url)
|
||||
console.log('[EpubReader] initEpub', reader.url)
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.url, {
|
||||
width: window.innerWidth,
|
||||
@@ -305,23 +317,34 @@ export default {
|
||||
})
|
||||
|
||||
reader.book.ready.then(() => {
|
||||
// load saved progress
|
||||
// when not checking spine first uncaught exception is thrown
|
||||
console.log('%c [EpubReader] Book ready', 'color:cyan;')
|
||||
|
||||
let displayCfi = reader.book.locations.start
|
||||
if (this.savedEbookLocation && reader.book.spine.get(this.savedEbookLocation)) {
|
||||
reader.rendition.display(this.savedEbookLocation)
|
||||
} else {
|
||||
reader.rendition.display(reader.book.locations.start)
|
||||
displayCfi = this.savedEbookLocation
|
||||
}
|
||||
|
||||
reader.rendition.on('rendered', () => {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
reader.rendition.on('rendered', (section, view) => {
|
||||
this.applyTheme()
|
||||
console.log('%c [EpubReader] Rendition rendered', 'color:red;', section, view)
|
||||
})
|
||||
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
|
||||
reader.rendition.on('displayError', (err) => {
|
||||
console.log('Display error', err)
|
||||
console.log('[EpubReader] Display error', err)
|
||||
})
|
||||
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
@@ -335,11 +358,23 @@ export default {
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
this.totalLocations = reader.book.locations.length()
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
applyTheme() {
|
||||
|
||||
@@ -146,7 +146,7 @@ export default {
|
||||
|
||||
// Update server item
|
||||
if (this.serverLibraryItemId) {
|
||||
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||
this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||
console.error('PdfReader.updateProgress failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -217,7 +217,6 @@ export default {
|
||||
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||
}
|
||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||
|
||||
if (this.ebookFileId) {
|
||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
||||
}
|
||||
@@ -282,14 +281,13 @@ export default {
|
||||
// Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
|
||||
const touchTimeMs = Date.now() - this.touchstartTime
|
||||
if (touchTimeMs >= 1000) {
|
||||
console.log('Touch too long', touchTimeMs)
|
||||
return
|
||||
}
|
||||
|
||||
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
||||
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
||||
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
|
||||
if (touchDistance < 60) {
|
||||
if (touchDistance < 30) {
|
||||
if (this.showSettingsModal) {
|
||||
this.showSettingsModal = false
|
||||
} else {
|
||||
@@ -302,11 +300,13 @@ export default {
|
||||
return
|
||||
}
|
||||
this.hideToolbar()
|
||||
if (this.touchendX < this.touchstartX) {
|
||||
this.next()
|
||||
}
|
||||
if (this.touchendX > this.touchstartX) {
|
||||
this.prev()
|
||||
if (!this.isEpub) {
|
||||
if (this.touchendX < this.touchstartX) {
|
||||
this.next()
|
||||
}
|
||||
if (this.touchendX > this.touchstartX) {
|
||||
this.prev()
|
||||
}
|
||||
}
|
||||
},
|
||||
showToolbar() {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||
<p v-if="media.duration" class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
|
||||
@@ -83,8 +83,8 @@ export default {
|
||||
},
|
||||
updateEbookStatus() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.selectedFile.ino}/status`)
|
||||
this.$nativeHttp
|
||||
.patch(`/api/items/${this.libraryItemId}/ebook/${this.selectedFile.ino}/status`)
|
||||
.then(() => {
|
||||
this.$toast.success('Ebook updated')
|
||||
})
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex items-center w-full">
|
||||
<div class="h-full relative" :style="{ width: '50px' }">
|
||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="item-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<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 v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
|
||||
<p class="text-xxs text-gray-400">{{ itemDuration }}</p>
|
||||
<div class="w-full px-1.5 pb-1.5">
|
||||
<div class="w-full h-full p-2 rounded-lg relative bg-bg overflow-hidden">
|
||||
<nuxt-link v-if="libraryItem" :to="itemUrl" class="flex items-center w-full">
|
||||
<div class="h-full relative" :style="{ width: '50px' }">
|
||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</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>
|
||||
<div class="item-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<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 v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
|
||||
<p class="text-xxs text-gray-400">{{ itemDuration }}</p>
|
||||
</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>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
<button class="w-8 h-8 rounded-full flex items-center justify-center" @click.stop.prevent="showMore">
|
||||
<span class="material-icons">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<div class="absolute bottom-0 left-0 h-0.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: progressPercent * 100 + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,12 +38,13 @@ export default {
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false
|
||||
}
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
itemUrl() {
|
||||
if (this.episodeId) return `/item/${this.libraryItem.id}/${this.episodeId}`
|
||||
return `/item/${this.libraryItem.id}`
|
||||
},
|
||||
libraryItem() {
|
||||
return this.item.libraryItem || {}
|
||||
},
|
||||
@@ -57,6 +66,12 @@ export default {
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem.mediaType
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
tracks() {
|
||||
if (this.episode) return []
|
||||
return this.media.tracks || []
|
||||
@@ -92,12 +107,6 @@ export default {
|
||||
coverWidth() {
|
||||
return 50
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
|
||||
},
|
||||
@@ -107,9 +116,31 @@ export default {
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
},
|
||||
userItemProgress() {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, this.episodeId)
|
||||
},
|
||||
userIsFinished() {
|
||||
return !!this.userItemProgress?.isFinished
|
||||
},
|
||||
progressPercent() {
|
||||
return Math.max(Math.min(1, this.userItemProgress?.progress || 0), 0)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showMore() {
|
||||
const playlistItem = {
|
||||
libraryItem: this.libraryItem,
|
||||
episode: this.episode
|
||||
}
|
||||
if (this.localLibraryItem) {
|
||||
playlistItem.libraryItem.localLibraryItem = this.localLibraryItem
|
||||
}
|
||||
if (this.localEpisode && playlistItem.episode) {
|
||||
playlistItem.episode.localEpisode = this.localEpisode
|
||||
}
|
||||
this.$emit('showMore', playlistItem)
|
||||
},
|
||||
async playClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.streamIsPlaying) {
|
||||
@@ -135,7 +166,7 @@ export default {
|
||||
|
||||
<style>
|
||||
.item-table-content {
|
||||
width: calc(100% - 82px);
|
||||
max-width: calc(100% - 82px);
|
||||
width: calc(100% - 114px);
|
||||
max-width: calc(100% - 114px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full bg-primary bg-opacity-40">
|
||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||
<p class="pr-4">Playlist Items</p>
|
||||
<div class="w-full bg-primary/50 rounded-lg">
|
||||
<div class="w-full h-14 flex items-center px-3">
|
||||
<p class="pr-2">Playlist Items</p>
|
||||
|
||||
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
|
||||
@@ -11,7 +11,7 @@
|
||||
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
<template v-for="item in items">
|
||||
<tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" />
|
||||
<tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" @showMore="showMore" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,7 +41,11 @@ export default {
|
||||
return this.$elapsedPrettyExtended(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
showMore(playlistItem) {
|
||||
this.$emit('showMore', playlistItem)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -280,8 +280,8 @@ export default {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
this.$nativeHttp
|
||||
.patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
|
||||
@@ -209,8 +209,8 @@ export default {
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
||||
this.$nativeHttp
|
||||
.get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode download queue cleared')
|
||||
this.episodeDownloadQueued = []
|
||||
@@ -230,7 +230,7 @@ export default {
|
||||
return this.$toast.error('Podcast does not have an RSS Feed')
|
||||
}
|
||||
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)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
|
||||
@@ -286,8 +286,8 @@ export default {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
this.$nativeHttp
|
||||
.patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
|
||||
@@ -34,7 +34,7 @@ export default {
|
||||
return this.$store.state.attemptingConnection
|
||||
},
|
||||
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.attemptingConnection) {
|
||||
return 'cloud_sync'
|
||||
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/Podfile.lock
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
|
||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
||||
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
|
||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; };
|
||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
@@ -99,6 +100,7 @@
|
||||
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
||||
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
|
||||
4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* Audiobookshelf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Audiobookshelf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -302,6 +304,7 @@
|
||||
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
||||
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
||||
4D91EEC52A40F28D004807ED /* EBookFile.swift */,
|
||||
4DABC04E2B0139CA000F6264 /* User.swift */,
|
||||
);
|
||||
path = server;
|
||||
sourceTree = "<group>";
|
||||
@@ -542,6 +545,7 @@
|
||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||
@@ -724,12 +728,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.65;
|
||||
MARKETING_VERSION = 0.9.67;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -748,12 +752,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.65;
|
||||
MARKETING_VERSION = 0.9.67;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 13,
|
||||
schemaVersion: 14,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.audiobookshelf.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>audiobookshelf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
||||
@@ -2,5 +2,10 @@
|
||||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,25 +31,8 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
}
|
||||
|
||||
@objc func onReady(_ call: CAPPluginCall) {
|
||||
Task { await self.restorePlaybackSession() }
|
||||
}
|
||||
|
||||
func restorePlaybackSession() async {
|
||||
do {
|
||||
// Fetch the most recent active session
|
||||
let activeSession = try Realm(queue: nil).objects(PlaybackSession.self).where({
|
||||
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}).last?.freeze()
|
||||
|
||||
if let activeSession = activeSession {
|
||||
PlayerHandler.stopPlayback(currentSessionId: activeSession.id)
|
||||
await PlayerProgress.shared.syncFromServer()
|
||||
try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to restore playback session")
|
||||
debugPrint(error)
|
||||
}
|
||||
// TODO: Was used to notify when Abs UI was ready so that last played media could be opened - this was buggy and removed
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func startPlaybackSession(_ session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) throws {
|
||||
|
||||
@@ -21,8 +21,8 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
|
||||
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(syncLocalSessionsWithServer, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateLocalEbookProgress, CAPPluginReturnPromise);
|
||||
|
||||
@@ -146,17 +146,16 @@ public class AbsDatabase: CAPPlugin {
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
|
||||
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
||||
logger.log("syncLocalSessionsWithServer: Starting")
|
||||
guard Store.serverConfig != nil else {
|
||||
call.reject("syncLocalMediaProgressWithServer not connected to server")
|
||||
return
|
||||
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
ApiClient.syncMediaProgress { results in
|
||||
do {
|
||||
call.resolve(try results.asDictionary())
|
||||
} catch {
|
||||
call.reject("Failed to report synced media progress")
|
||||
}
|
||||
|
||||
Task {
|
||||
await ApiClient.syncLocalSessionsWithServer()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
||||
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
PODS:
|
||||
- Alamofire (5.6.4)
|
||||
- Capacitor (5.4.0):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (5.0.6):
|
||||
- Capacitor
|
||||
- CapacitorBrowser (5.1.0):
|
||||
- Capacitor
|
||||
- CapacitorClipboard (5.0.6):
|
||||
- Capacitor
|
||||
- CapacitorCordova (5.4.0)
|
||||
- CapacitorDialog (5.0.6):
|
||||
- Capacitor
|
||||
- CapacitorHaptics (5.0.6):
|
||||
- Capacitor
|
||||
- CapacitorNetwork (5.0.6):
|
||||
- Capacitor
|
||||
- CapacitorPreferences (5.0.6):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (5.0.6):
|
||||
- Capacitor
|
||||
- CordovaPlugins (5.4.0):
|
||||
- CapacitorCordova
|
||||
- Realm (10.36.0):
|
||||
- Realm/Headers (= 10.36.0)
|
||||
- Realm/Headers (10.36.0)
|
||||
- RealmSwift (10.36.0):
|
||||
- Realm (= 10.36.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire (~> 5.5)
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||
- "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)"
|
||||
- "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)"
|
||||
- "CapacitorNetwork (from `../../node_modules/@capacitor/network`)"
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- CordovaPlugins (from `../capacitor-cordova-ios-plugins`)
|
||||
- RealmSwift (~> 10)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Alamofire
|
||||
- Realm
|
||||
- RealmSwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorApp:
|
||||
:path: "../../node_modules/@capacitor/app"
|
||||
CapacitorBrowser:
|
||||
:path: "../../node_modules/@capacitor/browser"
|
||||
CapacitorClipboard:
|
||||
:path: "../../node_modules/@capacitor/clipboard"
|
||||
CapacitorCordova:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorDialog:
|
||||
:path: "../../node_modules/@capacitor/dialog"
|
||||
CapacitorHaptics:
|
||||
:path: "../../node_modules/@capacitor/haptics"
|
||||
CapacitorNetwork:
|
||||
:path: "../../node_modules/@capacitor/network"
|
||||
CapacitorPreferences:
|
||||
:path: "../../node_modules/@capacitor/preferences"
|
||||
CapacitorStatusBar:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
CordovaPlugins:
|
||||
:path: "../capacitor-cordova-ios-plugins"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
||||
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
||||
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
||||
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
||||
CapacitorClipboard: 77edf49827ea21da2a9c05c690a4a6a4d07199c4
|
||||
CapacitorCordova: 66ce22f9976de30fd816f746e9e92e07d6befafd
|
||||
CapacitorDialog: 0f3c15dfe9414b83bc64aef4078f1b92bcfead26
|
||||
CapacitorHaptics: 1fffc1217c7e64a472d7845be50fb0c2f7d4204c
|
||||
CapacitorNetwork: d80b3e79bef6ec37640ee2806c19771f07ff2d0c
|
||||
CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
|
||||
CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
|
||||
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
|
||||
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
||||
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
||||
|
||||
PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
@@ -10,6 +10,7 @@ import RealmSwift
|
||||
|
||||
class MediaProgress: EmbeddedObject, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var userId: String = ""
|
||||
@Persisted var libraryItemId: String = ""
|
||||
@Persisted var episodeId: String?
|
||||
@Persisted var duration: Double = 0
|
||||
@@ -23,7 +24,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
||||
@Persisted var finishedAt: Double?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, ebookLocation, ebookProgress, lastUpdate, startedAt, finishedAt
|
||||
case id, userId, libraryItemId, episodeId, duration, progress, currentTime, isFinished, ebookLocation, ebookProgress, lastUpdate, startedAt, finishedAt
|
||||
}
|
||||
|
||||
override init() {
|
||||
@@ -33,6 +34,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
||||
required init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
userId = try values.decode(String.self, forKey: .userId)
|
||||
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
|
||||
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||
duration = try values.doubleOrStringDecoder(key: .duration)
|
||||
@@ -40,7 +42,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
||||
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
|
||||
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
||||
ebookLocation = try values.decodeIfPresent(String.self, forKey: .ebookLocation)
|
||||
ebookProgress = try values.doubleOrStringDecoder(key: .ebookProgress)
|
||||
ebookProgress = try? values.doubleOrStringDecoder(key: .ebookProgress)
|
||||
lastUpdate = try values.doubleOrStringDecoder(key: .lastUpdate)
|
||||
startedAt = try values.doubleOrStringDecoder(key: .startedAt)
|
||||
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
|
||||
@@ -49,6 +51,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||
try container.encode(episodeId, forKey: .episodeId)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// User.swift
|
||||
// Audiobookshelf
|
||||
//
|
||||
// Created by advplyr on 11/12/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class User: EmbeddedObject, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var username: String = ""
|
||||
@Persisted var mediaProgress = List<MediaProgress>()
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, username, mediaProgress
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
username = try values.decode(String.self, forKey: .username)
|
||||
if let progresses = try? values.decode([MediaProgress].self, forKey: .mediaProgress) {
|
||||
mediaProgress.append(objectsIn: progresses)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(username, forKey: .username)
|
||||
try container.encode(Array(mediaProgress), forKey: .mediaProgress)
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,6 @@ class AudioPlayer: NSObject {
|
||||
internal var currentTrackIndex = 0
|
||||
private var allPlayerItems:[AVPlayerItem] = []
|
||||
|
||||
private var pausedTimer: Timer?
|
||||
|
||||
// MARK: - Constructor
|
||||
init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
||||
self.playWhenReady = playWhenReady
|
||||
@@ -159,7 +157,6 @@ class AudioPlayer: NSObject {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
|
||||
|
||||
// Remove timers
|
||||
self.stopPausedTimer()
|
||||
self.removeSleepTimer()
|
||||
}
|
||||
|
||||
@@ -301,21 +298,6 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func startPausedTimer() {
|
||||
guard self.pausedTimer == nil else { return }
|
||||
self.queue.async {
|
||||
self.pausedTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] timer in
|
||||
self?.logger.log("PAUSE TIMER: Syncing from server")
|
||||
Task { await PlayerProgress.shared.syncFromServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPausedTimer() {
|
||||
self.pausedTimer?.invalidate()
|
||||
self.pausedTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
public func play(allowSeekBack: Bool = false, isInitializing: Bool = false) {
|
||||
guard self.isInitialized() || isInitializing else { return }
|
||||
@@ -345,9 +327,6 @@ class AudioPlayer: NSObject {
|
||||
private func resumePlayback() {
|
||||
logger.log("PLAY: Resuming playback")
|
||||
|
||||
// Stop the paused timer
|
||||
self.stopPausedTimer()
|
||||
|
||||
self.markAudioSessionAs(active: true)
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.play()
|
||||
@@ -380,8 +359,6 @@ class AudioPlayer: NSObject {
|
||||
|
||||
self.status = .paused
|
||||
updateNowPlaying()
|
||||
|
||||
self.startPausedTimer()
|
||||
}
|
||||
|
||||
public func seek(_ to: Double, from: String) {
|
||||
|
||||
@@ -116,8 +116,9 @@ extension AudioPlayer {
|
||||
// MARK: - Internal helpers
|
||||
|
||||
internal func handleTrackChangeForChapterSleepTimer() {
|
||||
// If no sleep timer is set, this does nothing
|
||||
self.setChapterSleepTimer(stopAt: self.sleepTimeChapterStopAt)
|
||||
if self.isChapterSleepTimerSet() {
|
||||
self.setChapterSleepTimer(stopAt: self.sleepTimeChapterStopAt)
|
||||
}
|
||||
}
|
||||
|
||||
private func decrementSleepTimerIfRunning() {
|
||||
|
||||
@@ -19,7 +19,6 @@ class PlayerHandler {
|
||||
|
||||
// Cleanup and sync old sessions
|
||||
cleanupOldSessions(currentSessionId: sessionId)
|
||||
Task { await PlayerProgress.shared.syncToServer() }
|
||||
|
||||
// Set now playing info
|
||||
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
|
||||
|
||||
@@ -36,28 +36,6 @@ class PlayerProgress {
|
||||
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||
}
|
||||
|
||||
public func syncToServer() async {
|
||||
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer")
|
||||
do {
|
||||
try await updateAllServerSessionFromLocalSession()
|
||||
} catch {
|
||||
logger.error("Failed to syncToServer")
|
||||
logger.error(error)
|
||||
}
|
||||
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||
}
|
||||
|
||||
public func syncFromServer() async {
|
||||
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer")
|
||||
do {
|
||||
try await updateLocalSessionFromServerMediaProgress()
|
||||
} catch {
|
||||
logger.error("Failed to syncFromServer")
|
||||
logger.error(error)
|
||||
}
|
||||
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SYNC LOGIC
|
||||
|
||||
@@ -175,6 +153,7 @@ class PlayerProgress {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Unused for now
|
||||
private func updateLocalSessionFromServerMediaProgress() async throws {
|
||||
logger.log("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
|
||||
guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: {
|
||||
|
||||
@@ -189,34 +189,66 @@ class ApiClient {
|
||||
return await postResource(endpoint: "api/session/local", parameters: session)
|
||||
}
|
||||
|
||||
public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) {
|
||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.map { $0.freeze() }
|
||||
|
||||
if ( !localMediaProgressList.isEmpty ) {
|
||||
let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList)
|
||||
logger.log("Sending sync local progress request with \(localMediaProgressList.count) progress items")
|
||||
postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in
|
||||
let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count)
|
||||
logger.log("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))")
|
||||
|
||||
if let updates = response.localProgressUpdates {
|
||||
for update in updates {
|
||||
do {
|
||||
try update.save()
|
||||
} catch {
|
||||
debugPrint("Failed to update local media progress")
|
||||
debugPrint(error)
|
||||
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions))
|
||||
}
|
||||
|
||||
public static func syncLocalSessionsWithServer() async {
|
||||
do {
|
||||
// Sync server progress with local media progress
|
||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.map { $0.freeze() }
|
||||
logger.log("syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server")
|
||||
|
||||
if (localMediaProgressList.isEmpty) {
|
||||
logger.log("syncLocalSessionsWithServer: No local progress to sync")
|
||||
} else {
|
||||
let currentUser = await ApiClient.getCurrentUser()
|
||||
guard let currentUser = currentUser else {
|
||||
logger.log("syncLocalSessionsWithServer: No User")
|
||||
return
|
||||
}
|
||||
try currentUser.mediaProgress.forEach { mediaProgress in
|
||||
let localMediaProgress = localMediaProgressList.first { lmp in
|
||||
if (lmp.episodeId != nil) {
|
||||
return lmp.episodeId == mediaProgress.episodeId
|
||||
} else {
|
||||
return lmp.libraryItemId == mediaProgress.libraryItemId
|
||||
}
|
||||
}
|
||||
if (localMediaProgress != nil && mediaProgress.lastUpdate > localMediaProgress!.lastUpdate) {
|
||||
logger.log("syncLocalSessionsWithServer: Updating local media progress \(localMediaProgress!.id) with server media progress")
|
||||
if let localMediaProgress = localMediaProgress?.thaw() {
|
||||
try localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
}
|
||||
} else if (localMediaProgress != nil) {
|
||||
logger.log("syncLocalSessionsWithServer: Local progress for \(localMediaProgress!.id) is more recent then server progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send saved playback sessions to server and remove them from db
|
||||
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.map { $0.freeze() }
|
||||
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server")
|
||||
if (!playbackSessions.isEmpty) {
|
||||
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
||||
if (success) {
|
||||
// Remove sessions from db
|
||||
try playbackSessions.forEach { session in
|
||||
if (!session.isActiveSession) {
|
||||
if let session = session.thaw() {
|
||||
try session.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback(resultsPayload)
|
||||
}
|
||||
} else {
|
||||
logger.log("No local media progress to sync")
|
||||
callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0))
|
||||
} catch {
|
||||
debugPrint(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +266,11 @@ class ApiClient {
|
||||
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
|
||||
}
|
||||
|
||||
public static func getCurrentUser() async -> User? {
|
||||
logger.log("getCurrentUser")
|
||||
return await getResource(endpoint: "api/me", decodable: User.self)
|
||||
}
|
||||
|
||||
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
||||
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
||||
if episodeId != nil {
|
||||
@@ -241,9 +278,10 @@ class ApiClient {
|
||||
}
|
||||
|
||||
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in
|
||||
callback(obj)
|
||||
callback(obj)
|
||||
}
|
||||
}
|
||||
|
||||
public static func pingServer() async -> Bool {
|
||||
var status = true
|
||||
AF.request("\(Store.serverConfig!.address)/ping", method: .get).responseDecodable(of: PingResponsePayload.self) { response in
|
||||
@@ -287,6 +325,10 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
|
||||
var numLocalProgressUpdates: Int?
|
||||
}
|
||||
|
||||
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
||||
var sessions: [PlaybackSession]
|
||||
}
|
||||
|
||||
struct Connectivity {
|
||||
static private let sharedInstance = NetworkReachabilityManager()!
|
||||
static var isConnectedToInternet:Bool {
|
||||
|
||||
@@ -241,6 +241,16 @@ class Database {
|
||||
}
|
||||
}
|
||||
|
||||
public func getAllPlaybackSessions() -> [PlaybackSession] {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
return Array(realm.objects(PlaybackSession.self))
|
||||
} catch {
|
||||
debugPrint(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public func getPlaybackSession(id: String) -> PlaybackSession? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
|
||||
+58
-57
@@ -3,10 +3,6 @@
|
||||
<app-appbar />
|
||||
<div id="content" class="overflow-hidden relative" :class="isPlayerOpen ? 'playerOpen' : ''">
|
||||
<Nuxt />
|
||||
|
||||
<div v-if="attemptingConnection" class="absolute top-0 left-0 z-50 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator text="Connecting to server..." class="mt-9" />
|
||||
</div>
|
||||
</div>
|
||||
<app-audio-player-container ref="streamContainer" />
|
||||
<modals-libraries-modal />
|
||||
@@ -19,12 +15,15 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CapacitorHttp } from '@capacitor/core'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
inittingLibraries: false,
|
||||
hasMounted: false,
|
||||
disconnectTime: 0
|
||||
disconnectTime: 0,
|
||||
timeLostFocus: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -45,8 +44,7 @@ export default {
|
||||
if (timeSinceDisconnect > 5000) {
|
||||
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||
setTimeout(() => {
|
||||
// TODO: Some issue here
|
||||
this.syncLocalMediaProgress()
|
||||
this.syncLocalSessions()
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
@@ -96,6 +94,21 @@ export default {
|
||||
|
||||
await this.$store.dispatch('user/loadUserSettings')
|
||||
},
|
||||
async postRequest(url, data, headers, connectTimeout = 30000) {
|
||||
const options = {
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
connectTimeout
|
||||
}
|
||||
const response = await CapacitorHttp.post(options)
|
||||
console.log('[default] POST request response', response)
|
||||
if (response.status >= 400) {
|
||||
throw new Error(response.data)
|
||||
} else {
|
||||
return response.data
|
||||
}
|
||||
},
|
||||
async attemptConnection() {
|
||||
console.warn('[default] attemptConnection')
|
||||
if (!this.networkConnected) {
|
||||
@@ -125,10 +138,8 @@ export default {
|
||||
|
||||
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
|
||||
|
||||
const authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` }, timeout: 3000 }).catch((error) => {
|
||||
console.error('[Server] Server auth failed', error)
|
||||
const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.error = errorMsg
|
||||
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 10000).catch((error) => {
|
||||
console.error('[default] Server auth failed', error)
|
||||
return false
|
||||
})
|
||||
if (!authRes) {
|
||||
@@ -136,8 +147,9 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const { user, userDefaultLibraryId, serverSettings } = authRes
|
||||
const { user, userDefaultLibraryId, serverSettings, ereaderDevices } = authRes
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
|
||||
// Set library - Use last library if set and available fallback to default user library
|
||||
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||
@@ -190,44 +202,16 @@ export default {
|
||||
|
||||
console.log('[default] Calling syncLocalSessions')
|
||||
const response = await this.$db.syncLocalSessionsWithServer()
|
||||
if (response && response.error) {
|
||||
if (response?.error) {
|
||||
console.error('[default] Failed to sync local sessions', response.error)
|
||||
} else {
|
||||
console.log('[default] Successfully synced local sessions')
|
||||
}
|
||||
},
|
||||
async syncLocalMediaProgress() {
|
||||
if (!this.user) {
|
||||
console.log('[default] No need to sync local media progress - not connected to server')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[default] Calling syncLocalMediaProgress')
|
||||
const response = await this.$db.syncLocalMediaProgressWithServer()
|
||||
if (!response) {
|
||||
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
|
||||
return
|
||||
}
|
||||
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates, serverProgressUpdates } = response
|
||||
if (numLocalMediaProgressForServer > 0) {
|
||||
if (serverProgressUpdates && serverProgressUpdates.length) {
|
||||
serverProgressUpdates.forEach((progress) => {
|
||||
console.log(`[default] Server progress was updated ${progress.id}`)
|
||||
this.$store.commit('user/updateUserMediaProgress', progress)
|
||||
})
|
||||
}
|
||||
|
||||
if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) {
|
||||
console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`)
|
||||
} else {
|
||||
console.log('[default] syncLocalMediaProgress No updates were necessary')
|
||||
}
|
||||
} else {
|
||||
console.log('[default] syncLocalMediaProgress No local media progress to sync')
|
||||
// Reload local media progresses
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
console.log('[default] userUpdated:', JSON.stringify(user))
|
||||
// console.log('[default] userUpdated:', JSON.stringify(user))
|
||||
if (this.user && this.user.id == user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
}
|
||||
@@ -236,13 +220,6 @@ export default {
|
||||
const prog = payload.data // MediaProgress
|
||||
console.log(`[default] userMediaProgressUpdate checking for local media progress ${payload.id}`)
|
||||
|
||||
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
|
||||
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
||||
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
||||
console.log('[default] userMediaProgressUpdated for current open media item', payload.data.currentTime)
|
||||
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||
}
|
||||
|
||||
// Update local media progress if exists
|
||||
const localProg = await this.$db.getLocalMediaProgressForServerItem({ libraryItemId: prog.libraryItemId, episodeId: prog.episodeId })
|
||||
|
||||
@@ -255,6 +232,13 @@ export default {
|
||||
console.log(`[default] syncing progress server lastUpdate > local lastUpdate. server currentTime=${prog.currentTime} local currentTime=${localProg.currentTime} | server/local isFinished=${prog.isFinished}/${localProg.isFinished}`)
|
||||
}
|
||||
|
||||
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
|
||||
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
||||
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
||||
console.log('[default] userMediaProgressUpdated for current open media item', payload.data.currentTime)
|
||||
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||
}
|
||||
|
||||
// Server progress is more up-to-date
|
||||
console.log(`[default] syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}`)
|
||||
const payload = {
|
||||
@@ -297,9 +281,30 @@ export default {
|
||||
console.log(`[default] local media progress updated for ${newLocalMediaProgress.id}`)
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
},
|
||||
async visibilityChanged() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const elapsedTimeOutOfFocus = Date.now() - this.timeLostFocus
|
||||
console.log(`✅ [default] device visibility: has focus (${elapsedTimeOutOfFocus}ms out of focus)`)
|
||||
// If device out of focus for more than 30s then reload local media progress
|
||||
if (elapsedTimeOutOfFocus > 30000) {
|
||||
console.log(`✅ [default] device visibility: reloading local media progress`)
|
||||
// Reload local media progresses
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
}
|
||||
if (document.visibilityState === 'visible') {
|
||||
this.$eventBus.$emit('device-focus-update', true)
|
||||
}
|
||||
} else {
|
||||
console.log('⛔️ [default] device visibility: does NOT have focus')
|
||||
this.timeLostFocus = Date.now()
|
||||
this.$eventBus.$emit('device-focus-update', false)
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
document.addEventListener('visibilitychange', this.visibilityChanged)
|
||||
|
||||
this.$socket.on('user_updated', this.userUpdated)
|
||||
this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated)
|
||||
|
||||
@@ -322,12 +327,7 @@ export default {
|
||||
}
|
||||
|
||||
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
||||
if (this.$platform === 'ios') {
|
||||
// TODO: Update ios to not use this
|
||||
await this.syncLocalMediaProgress()
|
||||
} else {
|
||||
await this.syncLocalSessions()
|
||||
}
|
||||
await this.syncLocalSessions()
|
||||
|
||||
this.loadSavedSettings()
|
||||
this.hasMounted = true
|
||||
@@ -337,6 +337,7 @@ export default {
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('visibilitychange', this.visibilityChanged)
|
||||
this.$socket.off('user_updated', this.userUpdated)
|
||||
this.$socket.off('user_media_progress_updated', this.userMediaProgressUpdated)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default {
|
||||
'@/plugins/localStore.js',
|
||||
'@/plugins/init.client.js',
|
||||
'@/plugins/axios.js',
|
||||
'@/plugins/nativeHttp.js',
|
||||
'@/plugins/capacitor/index.js',
|
||||
'@/plugins/capacitor/AbsAudioPlayer.js',
|
||||
'@/plugins/toast.js',
|
||||
|
||||
Generated
+3404
-2666
File diff suppressed because it is too large
Load Diff
+13
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.65-beta",
|
||||
"version": "0.9.67-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
@@ -13,16 +13,17 @@
|
||||
"ionic:serve": "npm run start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^4.0.0",
|
||||
"@capacitor/app": "^4.0.0",
|
||||
"@capacitor/clipboard": "^4.1.0",
|
||||
"@capacitor/core": "^4.0.0",
|
||||
"@capacitor/dialog": "^4.0.0",
|
||||
"@capacitor/haptics": "^4.0.0",
|
||||
"@capacitor/ios": "^4.0.0",
|
||||
"@capacitor/network": "^4.0.0",
|
||||
"@capacitor/preferences": "^4.0.2",
|
||||
"@capacitor/status-bar": "^4.0.0",
|
||||
"@capacitor/android": "^5.0.0",
|
||||
"@capacitor/app": "^5.0.6",
|
||||
"@capacitor/browser": "^5.1.0",
|
||||
"@capacitor/clipboard": "^5.0.0",
|
||||
"@capacitor/core": "^5.0.0",
|
||||
"@capacitor/dialog": "^5.0.0",
|
||||
"@capacitor/haptics": "^5.0.0",
|
||||
"@capacitor/ios": "^5.0.0",
|
||||
"@capacitor/network": "^5.0.0",
|
||||
"@capacitor/preferences": "^5.0.0",
|
||||
"@capacitor/status-bar": "^5.0.0",
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@teckel/vue-pdf": "^4.3.5",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -41,7 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.13.15",
|
||||
"@babel/preset-env": "7.13.15",
|
||||
"@capacitor/cli": "^4.0.0",
|
||||
"@capacitor/cli": "^5.0.0",
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"postcss": "^8.3.5",
|
||||
"tailwindcss": "^3.3.2"
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
<ui-text-input-with-label :value="username" label="Username" disabled class="my-2" />
|
||||
|
||||
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">Logout<span class="material-icons" style="font-size: 1.1rem">logout</span></ui-btn>
|
||||
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">Switch Server/User<span class="material-icons" style="font-size: 1.1rem">logout</span></ui-btn>
|
||||
|
||||
<div class="flex justify-end items-center m-4 gap-3 right-0 bottom-0 absolute">
|
||||
<p class="text-smtext-yellow-400 text-right">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf-app" target="_blank">GitHub</a></p>
|
||||
@@ -49,7 +49,7 @@ export default {
|
||||
async logout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export default {
|
||||
await this.$db.logout()
|
||||
this.$localStore.removeLastLibraryId()
|
||||
this.$store.commit('user/logout')
|
||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||
this.$router.push('/connect')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,9 +26,12 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
if (!this.currentLibraryId) {
|
||||
return
|
||||
}
|
||||
this.loadedLibraryId = this.currentLibraryId
|
||||
this.authors = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/authors`)
|
||||
this.authors = await this.$nativeHttp
|
||||
.get(`/api/libraries/${this.currentLibraryId}/authors`)
|
||||
.then((response) => response.authors)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load authors', error)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="w-full h-full min-h-full relative">
|
||||
<div v-if="attemptingConnection" class="w-full pt-4 flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
<p class="pl-4">Attempting server connection...</p>
|
||||
</div>
|
||||
<div v-if="shelves.length && isLoading" class="w-full pt-4 flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
<p class="pl-4">Loading server data...</p>
|
||||
@@ -232,7 +236,7 @@ export default {
|
||||
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
|
||||
|
||||
if (isConnectedToServerWithInternet) {
|
||||
const categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1&include=rssfeed`).catch((error) => {
|
||||
const categories = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1&include=rssfeed,numEpisodesIncomplete`).catch((error) => {
|
||||
console.error('[categories] Failed to fetch categories', error)
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async addEpisodeToPlaylist(episode) {
|
||||
const libraryItem = await this.$axios.$get(`/api/items/${episode.libraryItemId}`).catch((error) => {
|
||||
const libraryItem = await this.$nativeHttp.get(`/api/items/${episode.libraryItemId}`).catch((error) => {
|
||||
console.error('Failed to get library item', error)
|
||||
this.$toast.error('Failed to get library item')
|
||||
return null
|
||||
@@ -42,7 +42,7 @@ export default {
|
||||
async loadRecentEpisodes(page = 0) {
|
||||
this.loadedLibraryId = this.currentLibraryId
|
||||
this.processing = true
|
||||
const episodePayload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
|
||||
const episodePayload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
|
||||
console.error('Failed to get recent episodes', error)
|
||||
this.$toast.error('Failed to get recent episodes')
|
||||
return null
|
||||
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
},
|
||||
async checkRSSFeed(rssFeed) {
|
||||
this.processing = true
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed }).catch((error) => {
|
||||
var payload = await this.$nativeHttp.post(`/api/podcasts/feed`, { rssFeed }).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
async submitSearch(term) {
|
||||
this.processing = true
|
||||
this.termSearched = ''
|
||||
const results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
||||
const results = await this.$nativeHttp.get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
||||
console.error('Search request failed', error)
|
||||
return []
|
||||
})
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||
const payload = await this.$nativeHttp.post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, app, store, redirect }) {
|
||||
var series = await app.$axios.$get(`/api/series/${params.id}`).catch((error) => {
|
||||
var series = await app.$nativeHttp.get(`/api/series/${params.id}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ export default {
|
||||
return redirect(`/connect?redirect=${route.path}`)
|
||||
}
|
||||
|
||||
var collection = await app.$axios.$get(`/api/collections/${params.id}`).catch((error) => {
|
||||
var collection = await app.$nativeHttp.get(`/api/collections/${params.id}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
</div>
|
||||
<p class="hidden absolute short:block top-1.5 left-12 p-2text-xl">audiobookshelf</p>
|
||||
|
||||
<!-- <p class="absolute bottom-16 left-0 right-0 px-2 text-center text-error"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p> -->
|
||||
|
||||
<connection-server-connect-form v-if="deviceData" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||
console.log('Got lli', libraryItemId)
|
||||
} else if (store.state.user.serverConnectionConfig) {
|
||||
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
libraryItem = await app.$nativeHttp.get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -465,8 +465,8 @@ export default {
|
||||
}
|
||||
|
||||
if (serverItemProgressId) {
|
||||
await this.$axios
|
||||
.$delete(`/api/me/progress/${serverItemProgressId}`)
|
||||
await this.$nativeHttp
|
||||
.delete(`/api/me/progress/${serverItemProgressId}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
@@ -503,7 +503,7 @@ export default {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload).catch((error) => {
|
||||
this.$nativeHttp.patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
@@ -519,8 +519,8 @@ export default {
|
||||
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.serverLibraryItemId}/episode/${this.serverEpisodeId}?hard=1`)
|
||||
this.$nativeHttp
|
||||
.delete(`/api/podcasts/${this.serverLibraryItemId}/episode/${this.serverEpisodeId}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
this.$router.replace(`/item/${this.serverLibraryItemId}`)
|
||||
|
||||
+90
-241
@@ -27,9 +27,26 @@
|
||||
<p v-if="subtitle" class="text-gray-100 text-base">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
|
||||
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success text-center py-1 uppercase tracking-widest">connected</p>
|
||||
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 text-center py-1">{{ libraryItem.serverAddress }}</p>
|
||||
<div v-if="hasLocal" class="mx-1">
|
||||
<div v-if="isLocalOnly" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">Media is not linked to an Audiobookshelf server. No progress will be synced.</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingServerAddress" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">Media is linked to an Audiobookshelf server on a different address ({{ localLibraryItem.serverAddress }}). Progress will be synced when connected to this server address.</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingUser" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">Media is linked to this server but was downloaded by a different user. Progress will only be synced to the user that downloaded it.</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingConnectionConfig" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">Media is linked to a different server connection config. Downloaded User Id: {{ localLibraryItem.serverUserId }}. Downloaded Server Address: {{ localLibraryItem.serverAddress }}. Currently connected User Id: {{ user.id }}. Currently connected server address: {{ currentServerAddress }}.</p>
|
||||
</div>
|
||||
<div v-else-if="isLocalMatchingConnectionConfig" class="w-full rounded-md bg-success/10 border border-success p-4">
|
||||
<p class="text-sm">Downloaded media is linked to this server</p>
|
||||
</div>
|
||||
<div v-else-if="isLocal && libraryItem.serverAddress" class="w-full rounded-md bg-slate-300/10 border border-slate-300 p-4">
|
||||
<p class="text-sm">Linked to server {{ libraryItem.serverAddress }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="col-span-full">
|
||||
@@ -50,7 +67,7 @@
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 text-center" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 text-center">
|
||||
<p>Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-if="!useEBookProgress && !userIsFinished" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p v-else-if="userIsFinished" class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||
@@ -68,8 +85,8 @@
|
||||
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="bookAuthors && bookAuthors.length" class="text-sm">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link>
|
||||
<span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,</span>
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -79,8 +96,8 @@
|
||||
<div v-if="series && series.length" class="text-white text-opacity-60 uppercase text-sm">Series</div>
|
||||
<div v-if="series && series.length" class="truncate text-sm">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link>
|
||||
<span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -90,16 +107,16 @@
|
||||
<div v-if="narrators && narrators.length" class="text-white text-opacity-60 uppercase text-sm">{{ narrators.length === 1 ? 'Narrator' : 'Narrators' }}</div>
|
||||
<div v-if="narrators && narrators.length" class="truncate text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link>
|
||||
<span :key="index" v-if="index < narrators.length - 1">,</span>
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">{{ genres.length === 1 ? 'Genre' : 'Genres' }}</div>
|
||||
<div v-if="genres.length" class="truncate text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link>
|
||||
<span :key="index" v-if="index < genres.length - 1">,</span>
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -126,14 +143,16 @@
|
||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
|
||||
|
||||
<!-- modals -->
|
||||
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="libraryItem" :rss-feed="rssFeed" :processing.sync="processing" />
|
||||
|
||||
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
||||
|
||||
<modals-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
|
||||
|
||||
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
|
||||
|
||||
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
|
||||
</div>
|
||||
|
||||
<div v-show="processing" class="fixed top-0 left-0 w-screen h-screen flex items-center justify-center bg-black/50 z-50">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -149,8 +168,12 @@ export default {
|
||||
if (libraryItemId.startsWith('local')) {
|
||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||
console.log('Got lli', libraryItemId)
|
||||
// If library item is linked to the currently connected server then redirect to the page using the server library item id
|
||||
if (libraryItem?.libraryItemId && libraryItem?.serverAddress === store.getters['user/getServerAddress']) {
|
||||
return redirect(`/item/${libraryItem.libraryItemId}`)
|
||||
}
|
||||
} else if (store.state.user.serverConnectionConfig) {
|
||||
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1&include=rssfeed`).catch((error) => {
|
||||
libraryItem = await app.$nativeHttp.get(`/api/items/${libraryItemId}?expanded=1&include=rssfeed`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -175,11 +198,9 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resettingProgress: false,
|
||||
isProcessingReadUpdate: false,
|
||||
processing: false,
|
||||
showSelectLocalFolder: false,
|
||||
showMoreMenu: false,
|
||||
showDetailsModal: false,
|
||||
showFullscreenCover: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverBgIsLight: false,
|
||||
@@ -201,6 +222,10 @@ export default {
|
||||
isLocal() {
|
||||
return this.libraryItem.isLocal
|
||||
},
|
||||
isLocalOnly() {
|
||||
// TODO: Remove the possibility to have local only on android
|
||||
return this.isLocal && !this.libraryItem.libraryItemId
|
||||
},
|
||||
hasLocal() {
|
||||
// Server library item has matching local library item
|
||||
return this.isLocal || this.libraryItem.localLibraryItem
|
||||
@@ -221,11 +246,41 @@ export default {
|
||||
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) {
|
||||
if (this.currentServerAddress === this.libraryItem.serverAddress) {
|
||||
return this.libraryItem.libraryItemId
|
||||
}
|
||||
return null
|
||||
},
|
||||
localLibraryItemServerConnectionConfigId() {
|
||||
return this.localLibraryItem?.serverConnectionConfigId
|
||||
},
|
||||
currentServerAddress() {
|
||||
return this.$store.getters['user/getServerAddress']
|
||||
},
|
||||
currentServerConnectionConfigId() {
|
||||
return this.$store.getters['user/getServerConnectionConfigId']
|
||||
},
|
||||
/**
|
||||
* User is currently connected to a server and this local library item has the same server address
|
||||
*/
|
||||
isLocalMatchingServerAddress() {
|
||||
if (this.isLocalOnly || !this.localLibraryItem || !this.currentServerAddress) return false
|
||||
return this.localLibraryItem.serverAddress === this.currentServerAddress
|
||||
},
|
||||
/**
|
||||
* User is currently connected to a server and this local library item has the same user id
|
||||
*/
|
||||
isLocalMatchingUser() {
|
||||
if (this.isLocalOnly || !this.localLibraryItem || !this.user) return false
|
||||
return this.localLibraryItem.serverUserId === this.user.id
|
||||
},
|
||||
/**
|
||||
* User is currently connected to a server and this local library item has the same connection config id
|
||||
*/
|
||||
isLocalMatchingConnectionConfig() {
|
||||
if (this.isLocalOnly || !this.localLibraryItemServerConnectionConfigId || !this.currentServerConnectionConfigId) return false
|
||||
return this.localLibraryItemServerConnectionConfigId === this.currentServerConnectionConfigId
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
@@ -311,7 +366,7 @@ export default {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||
},
|
||||
userIsFinished() {
|
||||
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
|
||||
return !!this.userItemProgress?.isFinished
|
||||
},
|
||||
userTimeRemaining() {
|
||||
if (!this.userItemProgress) return 0
|
||||
@@ -324,10 +379,10 @@ export default {
|
||||
},
|
||||
progressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userItemProgress.ebookProgress), 0)
|
||||
return this.userItemProgress ? Math.max(Math.min(1, this.userItemProgress.progress), 0) : 0
|
||||
return Math.max(Math.min(1, this.userItemProgress?.progress || 0), 0)
|
||||
},
|
||||
userProgressFinishedAt() {
|
||||
return this.userItemProgress ? this.userItemProgress.finishedAt : 0
|
||||
return this.userItemProgress?.finishedAt || 0
|
||||
},
|
||||
isStreaming() {
|
||||
return this.isPlaying && !this.$store.getters['getIsCurrentSessionLocal']
|
||||
@@ -387,83 +442,6 @@ export default {
|
||||
isCasting() {
|
||||
return this.$store.state.isCasting
|
||||
},
|
||||
showRSSFeedOption() {
|
||||
if (!this.serverLibraryItemId) return false
|
||||
if (!this.rssFeed && !this.episodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
||||
|
||||
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||
return this.userIsAdminOrUp || this.rssFeed
|
||||
},
|
||||
moreMenuItems() {
|
||||
const items = []
|
||||
|
||||
if (!this.isPodcast) {
|
||||
// TODO: Implement on iOS
|
||||
if (!this.isIos) {
|
||||
items.push({
|
||||
text: 'History',
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.userIsFinished) {
|
||||
items.push({
|
||||
text: 'Mark as Finished',
|
||||
value: 'markFinished',
|
||||
icon: 'beenhere'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.progressPercent > 0) {
|
||||
items.push({
|
||||
text: 'Discard Progress',
|
||||
value: 'discardProgress',
|
||||
icon: 'backspace'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isPodcast && this.serverLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Add to Playlist',
|
||||
value: 'playlist',
|
||||
icon: 'playlist_add'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showRSSFeedOption) {
|
||||
items.push({
|
||||
text: this.rssFeed ? 'RSS Feed' : 'Open RSS Feed',
|
||||
value: 'rssFeed',
|
||||
icon: 'rss_feed'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.localLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Manage Local Files',
|
||||
value: 'manageLocal',
|
||||
icon: 'folder'
|
||||
})
|
||||
|
||||
if (!this.isPodcast) {
|
||||
items.push({
|
||||
text: 'Delete Local Item',
|
||||
value: 'deleteLocal',
|
||||
icon: 'delete'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
text: 'More Info',
|
||||
value: 'details',
|
||||
icon: 'info'
|
||||
})
|
||||
|
||||
return items
|
||||
},
|
||||
coverWidth() {
|
||||
let width = this.windowWidth - 94
|
||||
if (width > 325) return 325
|
||||
@@ -471,42 +449,9 @@ export default {
|
||||
|
||||
if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio
|
||||
return width
|
||||
},
|
||||
mediaId() {
|
||||
if (this.isPodcast) return null
|
||||
return this.serverLibraryItemId || this.localLibraryItemId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async deleteLocalItem() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
let confirmMessage = 'Remove local files of this item from your device?'
|
||||
if (this.serverLibraryItemId) {
|
||||
confirmMessage += ' The files on the server and your progress will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||
if (res?.success) {
|
||||
this.$toast.success('Deleted successfully')
|
||||
if (this.isLocal) {
|
||||
// If local then redirect to server version when available
|
||||
if (this.serverLibraryItemId) {
|
||||
this.$router.replace(`/item/${this.serverLibraryItemId}`)
|
||||
} else {
|
||||
this.$router.replace('/bookshelf')
|
||||
}
|
||||
} else {
|
||||
// Remove localLibraryItem
|
||||
this.$delete(this.libraryItem, 'localLibraryItem')
|
||||
}
|
||||
} else this.$toast.error('Failed to delete')
|
||||
}
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
@@ -521,39 +466,6 @@ export default {
|
||||
console.log(e)
|
||||
})
|
||||
},
|
||||
moreMenuAction(action) {
|
||||
this.showMoreMenu = false
|
||||
if (action === 'manageLocal') {
|
||||
this.$nextTick(() => {
|
||||
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
|
||||
})
|
||||
} else if (action === 'details') {
|
||||
this.showDetailsModal = true
|
||||
} else if (action === 'playlist') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: null }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'markFinished') {
|
||||
if (this.isProcessingReadUpdate) return
|
||||
this.toggleFinished()
|
||||
} else if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
} else if (action === 'discardProgress') {
|
||||
this.clearProgressClick()
|
||||
} else if (action === 'deleteLocal') {
|
||||
this.deleteLocalItem()
|
||||
} else if (action === 'rssFeed') {
|
||||
this.clickRSSFeed()
|
||||
}
|
||||
},
|
||||
clickRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.serverLibraryItemId,
|
||||
name: this.title,
|
||||
type: 'item',
|
||||
feed: this.rssFeed,
|
||||
hasEpisodesWithoutPubDate: this.episodes.some((ep) => !ep.pubDate)
|
||||
})
|
||||
},
|
||||
moreButtonPress() {
|
||||
this.showMoreMenu = true
|
||||
},
|
||||
@@ -602,7 +514,7 @@ export default {
|
||||
} else if (this.isLocal) {
|
||||
localEpisode = episode
|
||||
}
|
||||
const serverEpisodeId = !this.isLocal ? episodeId : localEpisode ? localEpisode.serverEpisodeId : null
|
||||
const serverEpisodeId = !this.isLocal ? episodeId : localEpisode?.serverEpisodeId || null
|
||||
|
||||
if (serverEpisodeId && this.serverLibraryItemId && this.isCasting) {
|
||||
// If casting and connected to server for local library item then send server library item id
|
||||
@@ -635,37 +547,6 @@ export default {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.serverLibraryItemId, startTime })
|
||||
}
|
||||
},
|
||||
async clearProgressClick() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to reset your progress?'
|
||||
})
|
||||
if (value) {
|
||||
this.resettingProgress = true
|
||||
const serverMediaProgressId = this.serverItemProgress?.id
|
||||
if (this.localLibraryItemId) {
|
||||
await this.$db.removeLocalMediaProgress(this.localLibraryItemId)
|
||||
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localLibraryItemId)
|
||||
}
|
||||
|
||||
if (this.serverLibraryItemId && serverMediaProgressId) {
|
||||
await this.$axios
|
||||
.$delete(`/api/me/progress/${serverMediaProgressId}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
this.$store.commit('user/removeMediaProgress', serverMediaProgressId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Progress reset failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
this.resettingProgress = false
|
||||
}
|
||||
},
|
||||
itemUpdated(libraryItem) {
|
||||
if (libraryItem.id === this.serverLibraryItemId) {
|
||||
console.log('Item Updated')
|
||||
@@ -767,48 +648,6 @@ export default {
|
||||
this.$set(this.libraryItem, 'localLibraryItem', item)
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
// Show confirm if item has progress since it will reset
|
||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to mark this item as Finished?'
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal) {
|
||||
const isFinished = !this.userIsFinished
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
}
|
||||
this.isProcessingReadUpdate = false
|
||||
} else {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
libraryChanged(libraryId) {
|
||||
if (this.libraryItem.libraryId !== libraryId) {
|
||||
this.$router.replace('/bookshelf')
|
||||
@@ -837,9 +676,19 @@ export default {
|
||||
console.log('RSS Feed Closed', data)
|
||||
this.rssFeed = null
|
||||
}
|
||||
},
|
||||
async setLibrary() {
|
||||
if (!this.libraryItem.libraryId) return
|
||||
await this.$store.dispatch('libraries/fetch', this.libraryItem.libraryId)
|
||||
this.$localStore.setLastLibraryId(this.libraryItem.libraryId)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// If library of this item is different from current library then switch libraries
|
||||
if (this.$store.state.libraries.currentLibraryId !== this.libraryItem.libraryId) {
|
||||
this.setLibrary()
|
||||
}
|
||||
|
||||
this.windowWidth = window.innerWidth
|
||||
window.addEventListener('resize', this.windowResized)
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
|
||||
@@ -11,10 +11,7 @@
|
||||
|
||||
<p class="mb-2 text-base text-white">Local Library Items ({{ localLibraryItems.length }})</p>
|
||||
|
||||
<div v-if="isScanning" class="w-full text-center p-4">
|
||||
<p>Scanning...</p>
|
||||
</div>
|
||||
<div v-else class="w-full media-item-container overflow-y-auto">
|
||||
<div class="w-full media-item-container overflow-y-auto">
|
||||
<template v-for="localLibraryItem in localLibraryItems">
|
||||
<nuxt-link :to="`/localMedia/item/${localLibraryItem.id}`" :key="localLibraryItem.id" class="flex my-1">
|
||||
<div class="w-12 h-12 min-w-12 min-h-12 bg-primary">
|
||||
@@ -43,15 +40,13 @@ import { AbsFileSystem } from '@/plugins/capacitor'
|
||||
export default {
|
||||
asyncData({ params, query }) {
|
||||
return {
|
||||
folderId: params.id,
|
||||
shouldScan: !!query.scan
|
||||
folderId: params.id
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localLibraryItems: [],
|
||||
folder: null,
|
||||
isScanning: false,
|
||||
removingFolder: false,
|
||||
showDialog: false
|
||||
}
|
||||
@@ -68,20 +63,7 @@ export default {
|
||||
},
|
||||
dialogItems() {
|
||||
if (this.isInternalStorage) return []
|
||||
const items = [
|
||||
{
|
||||
text: 'Scan',
|
||||
value: 'scan'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.localLibraryItems.length) {
|
||||
items.push({
|
||||
text: 'Force Re-Scan',
|
||||
value: 'rescan'
|
||||
})
|
||||
}
|
||||
|
||||
const items = []
|
||||
items.push({
|
||||
text: 'Remove',
|
||||
value: 'remove'
|
||||
@@ -107,11 +89,7 @@ export default {
|
||||
},
|
||||
dialogAction(action) {
|
||||
console.log('Dialog action', action)
|
||||
if (action == 'scan') {
|
||||
this.scanFolder()
|
||||
} else if (action == 'rescan') {
|
||||
this.scanFolder(true)
|
||||
} else if (action == 'remove') {
|
||||
if (action == 'remove') {
|
||||
this.removeFolder()
|
||||
}
|
||||
this.showDialog = false
|
||||
@@ -135,37 +113,6 @@ export default {
|
||||
play(mediaItem) {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: mediaItem.id })
|
||||
},
|
||||
async scanFolder(forceAudioProbe = false) {
|
||||
this.isScanning = true
|
||||
var response = await AbsFileSystem.scanFolder({ folderId: this.folderId, forceAudioProbe })
|
||||
|
||||
if (response && response.localLibraryItems) {
|
||||
var itemsAdded = response.itemsAdded
|
||||
var itemsUpdated = response.itemsUpdated
|
||||
var itemsRemoved = response.itemsRemoved
|
||||
var itemsUpToDate = response.itemsUpToDate
|
||||
var toastMessages = []
|
||||
if (itemsAdded) toastMessages.push(`${itemsAdded} Added`)
|
||||
if (itemsUpdated) toastMessages.push(`${itemsUpdated} Updated`)
|
||||
if (itemsRemoved) toastMessages.push(`${itemsRemoved} Removed`)
|
||||
if (itemsUpToDate) toastMessages.push(`${itemsUpToDate} Up-to-date`)
|
||||
this.$toast.info(`Folder scan complete:\n${toastMessages.join(' | ')}`)
|
||||
|
||||
// When all items are up-to-date then local media items are not returned
|
||||
if (response.localLibraryItems.length) {
|
||||
this.localLibraryItems = response.localLibraryItems.map((mi) => {
|
||||
if (mi.coverContentUrl) {
|
||||
mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverContentUrl)
|
||||
}
|
||||
return mi
|
||||
})
|
||||
console.log('Set Local Media Items', this.localLibraryItems.length)
|
||||
}
|
||||
} else {
|
||||
console.log('No Local media items found')
|
||||
}
|
||||
this.isScanning = false
|
||||
},
|
||||
async init() {
|
||||
var folder = await this.$db.getLocalFolder(this.folderId)
|
||||
this.folder = folder
|
||||
@@ -179,10 +126,6 @@ export default {
|
||||
coverPathSrc: lmi.coverContentUrl ? Capacitor.convertFileSrc(lmi.coverContentUrl) : null
|
||||
}
|
||||
})
|
||||
|
||||
if (this.shouldScan) {
|
||||
this.scanFolder()
|
||||
}
|
||||
},
|
||||
newLocalLibraryItem(item) {
|
||||
if (item.folderId == this.folderId) {
|
||||
|
||||
@@ -86,8 +86,6 @@ export default {
|
||||
} else {
|
||||
this.$toast.success('Folder permission success')
|
||||
}
|
||||
|
||||
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
|
||||
},
|
||||
async init() {
|
||||
const androidSdkVersion = await this.$getAndroidSDKVersion()
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
|
||||
<p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p>
|
||||
|
||||
<div v-if="isScanning" class="w-full text-center p-4">
|
||||
<p>Scanning...</p>
|
||||
</div>
|
||||
<div v-else class="w-full max-w-full media-item-container overflow-y-auto overflow-x-hidden relative pb-4" :class="{ 'media-order-changed': orderChanged }">
|
||||
<div class="w-full max-w-full media-item-container overflow-y-auto overflow-x-hidden relative pb-4" :class="{ 'media-order-changed': orderChanged }">
|
||||
<div v-if="!isPodcast && audioTracksCopy.length" class="w-full py-2">
|
||||
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
|
||||
|
||||
@@ -148,7 +145,6 @@ export default {
|
||||
removingItem: false,
|
||||
folderId: null,
|
||||
folder: null,
|
||||
isScanning: false,
|
||||
showDialog: false,
|
||||
selectedAudioTrack: null,
|
||||
selectedEpisode: null,
|
||||
@@ -229,19 +225,17 @@ export default {
|
||||
}
|
||||
]
|
||||
} else {
|
||||
var options = []
|
||||
if (!this.isIos && !this.isInternalStorage && !this.libraryItemId) {
|
||||
options.push({ text: 'Scan', value: 'scan' })
|
||||
options.push({ text: 'Force Re-Scan', value: 'rescan' })
|
||||
}
|
||||
options.push({ text: 'Delete local item', value: 'delete' })
|
||||
return options
|
||||
return [
|
||||
{
|
||||
text: 'Delete local item',
|
||||
value: 'delete'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
draggableUpdate() {
|
||||
console.log('Draggable Update')
|
||||
for (let i = 0; i < this.audioTracksCopy.length; i++) {
|
||||
var trackCopy = this.audioTracksCopy[i]
|
||||
var track = this.audioTracks[i]
|
||||
@@ -295,11 +289,7 @@ export default {
|
||||
console.log('Dialog action', action)
|
||||
await this.$hapticsImpact()
|
||||
|
||||
if (action == 'scan') {
|
||||
this.scanItem()
|
||||
} else if (action == 'rescan') {
|
||||
this.scanItem(true)
|
||||
} else if (action == 'delete') {
|
||||
if (action == 'delete') {
|
||||
this.deleteItem()
|
||||
} else if (action == 'track-delete') {
|
||||
if (this.isPodcast) this.deleteEpisode()
|
||||
@@ -377,25 +367,6 @@ export default {
|
||||
} else this.$toast.error('Failed to delete')
|
||||
}
|
||||
},
|
||||
async scanItem(forceAudioProbe = false) {
|
||||
if (this.isScanning) return
|
||||
|
||||
this.isScanning = true
|
||||
var response = await AbsFileSystem.scanLocalLibraryItem({ localLibraryItemId: this.localLibraryItemId, forceAudioProbe })
|
||||
|
||||
if (response && response.localLibraryItem) {
|
||||
if (response.updated) {
|
||||
this.$toast.success('Local item was updated')
|
||||
this.localLibraryItem = response.localLibraryItem
|
||||
} else {
|
||||
this.$toast.info('Local item was up to date')
|
||||
}
|
||||
} else {
|
||||
console.log('Failed')
|
||||
this.$toast.error('Something went wrong..')
|
||||
}
|
||||
this.isScanning = false
|
||||
},
|
||||
async init() {
|
||||
this.localLibraryItem = await this.$db.getLocalLibraryItem(this.localLibraryItemId)
|
||||
|
||||
|
||||
+22
-7
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
|
||||
<div class="w-full h-full overflow-y-auto py-6 md:p-8">
|
||||
<div class="w-full flex justify-center">
|
||||
<covers-playlist-cover :items="playlistItems" :width="180" :height="180" />
|
||||
</div>
|
||||
<div class="flex-grow py-6">
|
||||
<div class="flex items-center px-2">
|
||||
<div class="flex-grow px-1 py-6">
|
||||
<div class="flex items-center px-3">
|
||||
<h1 class="text-xl font-sans">
|
||||
{{ playlistName }}
|
||||
</h1>
|
||||
@@ -16,13 +16,18 @@
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="my-8 max-w-2xl px-2">
|
||||
<div class="my-8 max-w-2xl px-3">
|
||||
<p class="text-base text-gray-100">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<tables-playlist-items-table :items="playlistItems" :playlist-id="playlist.id" />
|
||||
<tables-playlist-items-table :items="playlistItems" :playlist-id="playlist.id" @showMore="showMore" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="selectedLibraryItem" :episode="selectedEpisode" hide-rss-feed-option :processing.sync="processing" />
|
||||
<div v-show="processing" class="fixed top-0 left-0 w-screen h-screen flex items-center justify-center bg-black/50 z-50">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -33,7 +38,7 @@ export default {
|
||||
return redirect(`/connect?redirect=${route.path}`)
|
||||
}
|
||||
|
||||
const playlist = await app.$axios.$get(`/api/playlists/${params.id}`).catch((error) => {
|
||||
const playlist = await app.$nativeHttp.get(`/api/playlists/${params.id}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -67,7 +72,12 @@ export default {
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showMoreMenu: false,
|
||||
processing: false,
|
||||
selectedLibraryItem: null,
|
||||
selectedEpisode: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
@@ -101,6 +111,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showMore(playlistItem) {
|
||||
this.selectedLibraryItem = playlistItem.libraryItem
|
||||
this.selectedEpisode = playlistItem.episode
|
||||
this.showMoreMenu = true
|
||||
},
|
||||
clickPlay() {
|
||||
const nextItem = this.playableItems.find((i) => {
|
||||
const prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId)
|
||||
|
||||
+15
-3
@@ -54,6 +54,15 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="tagResults.length" class="font-semibold text-sm mb-1 mt-2">Tags</p>
|
||||
<template v-for="tag in tagResults">
|
||||
<div :key="tag.name" class="w-full h-14 py-1">
|
||||
<nuxt-link :to="`/bookshelf/library?filter=tags.${$encode(tag.name)}`">
|
||||
<cards-tag-search-card :tag="tag.name" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -70,7 +79,8 @@ export default {
|
||||
podcastResults: [],
|
||||
seriesResults: [],
|
||||
authorResults: [],
|
||||
narratorResults: []
|
||||
narratorResults: [],
|
||||
tagResults: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -81,7 +91,7 @@ export default {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
totalResults() {
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length + this.tagResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -97,10 +107,11 @@ export default {
|
||||
this.seriesResults = []
|
||||
this.authorResults = []
|
||||
this.narratorResults = []
|
||||
this.tagResults = []
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
const results = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=5`).catch((error) => {
|
||||
const results = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=5`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return null
|
||||
})
|
||||
@@ -117,6 +128,7 @@ export default {
|
||||
this.seriesResults = results?.series || []
|
||||
this.authorResults = results?.authors || []
|
||||
this.narratorResults = results?.narrators || []
|
||||
this.tagResults = results?.tags || []
|
||||
},
|
||||
updateSearch(val) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
|
||||
+4
-3
@@ -8,7 +8,8 @@
|
||||
</div>
|
||||
<p class="pl-4">Use bookshelf view</p>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click.stop="toggleLockOrientation">
|
||||
<!-- screen.orientation.lock not supported on iOS webview -->
|
||||
<div v-if="!isiOS" class="flex items-center py-3" @click.stop="toggleLockOrientation">
|
||||
<div class="w-10 flex justify-center pointer-events-none">
|
||||
<ui-toggle-switch v-model="lockCurrentOrientation" />
|
||||
</div>
|
||||
@@ -135,7 +136,7 @@ export default {
|
||||
moreMenuSetting: '',
|
||||
settings: {
|
||||
disableAutoRewind: false,
|
||||
enableAltView: false,
|
||||
enableAltView: true,
|
||||
jumpForwardTime: 10,
|
||||
jumpBackwardsTime: 10,
|
||||
enableMp3IndexSeeking: false,
|
||||
@@ -414,7 +415,7 @@ export default {
|
||||
this.settings.enableAltView = !!deviceSettings.enableAltView
|
||||
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
|
||||
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
|
||||
this.settings.toggleEnableMp3IndexSeeking = !!deviceSettings.toggleEnableMp3IndexSeeking
|
||||
this.settings.enableMp3IndexSeeking = !!deviceSettings.enableMp3IndexSeeking
|
||||
|
||||
this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE'
|
||||
this.lockCurrentOrientation = this.settings.lockOrientation !== 'NONE'
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
|
||||
this.listeningStats = await this.$nativeHttp.get(`/api/me/listening-stats`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -88,6 +88,8 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
value: [{
|
||||
id: 'local_test',
|
||||
libraryItemId: 'test34',
|
||||
serverAddress: 'https://abs.test.com',
|
||||
serverUserId: 'test56',
|
||||
folderId: 'test1',
|
||||
absolutePath: 'a',
|
||||
contentUrl: 'c',
|
||||
@@ -194,10 +196,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
return null
|
||||
}
|
||||
|
||||
async syncLocalMediaProgressWithServer() {
|
||||
return null
|
||||
}
|
||||
|
||||
async syncLocalSessionsWithServer() {
|
||||
return null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user