mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-09 13:28:43 +02:00
Compare commits
41
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 |
@@ -33,9 +33,12 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 97
|
versionCode 98
|
||||||
versionName "0.9.66-beta"
|
versionName "0.9.67-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
manifestPlaceholders = [
|
||||||
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
|
]
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
@@ -122,9 +125,6 @@ dependencies {
|
|||||||
|
|
||||||
// Jackson for JSON
|
// Jackson for JSON
|
||||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
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'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_11
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-browser')
|
||||||
implementation project(':capacitor-clipboard')
|
implementation project(':capacitor-clipboard')
|
||||||
implementation project(':capacitor-dialog')
|
implementation project(':capacitor-dialog')
|
||||||
implementation project(':capacitor-haptics')
|
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>
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
"pkg": "@capacitor/app",
|
"pkg": "@capacitor/app",
|
||||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/browser",
|
||||||
|
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/clipboard",
|
"pkg": "@capacitor/clipboard",
|
||||||
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||||
|
|||||||
@@ -5,16 +5,10 @@ import android.net.Uri
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.anggrayudi.storage.file.*
|
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.data.*
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
|
||||||
import com.getcapacitor.JSObject
|
|
||||||
import org.json.JSONException
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
class FolderScanner(var ctx: Context) {
|
||||||
@@ -27,211 +21,6 @@ class FolderScanner(var ctx: Context) {
|
|||||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
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) {
|
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||||
|
|
||||||
@@ -385,7 +174,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
duration = mediaProgress.duration,
|
duration = mediaProgress.duration,
|
||||||
progress = mediaProgress.progress,
|
progress = mediaProgress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
currentTime = mediaProgress.currentTime,
|
||||||
isFinished = false,
|
isFinished = mediaProgress.isFinished,
|
||||||
ebookLocation = mediaProgress.ebookLocation,
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
ebookProgress = mediaProgress.ebookProgress,
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
@@ -552,7 +341,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
duration = mediaProgress.duration,
|
duration = mediaProgress.duration,
|
||||||
progress = mediaProgress.progress,
|
progress = mediaProgress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
currentTime = mediaProgress.currentTime,
|
||||||
isFinished = false,
|
isFinished = mediaProgress.isFinished,
|
||||||
ebookLocation = mediaProgress.ebookLocation,
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
ebookProgress = mediaProgress.ebookProgress,
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
@@ -564,6 +353,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
libraryItemId = downloadItem.libraryItemId,
|
libraryItemId = downloadItem.libraryItemId,
|
||||||
episodeId = downloadItem.episodeId)
|
episodeId = downloadItem.episodeId)
|
||||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||||
|
|
||||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||||
@@ -573,132 +363,4 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
cb(downloadItemScanResult)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
playerNotificationService.seekPlayer(pos)
|
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
|
||||||
|
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onChangeSpeed() {
|
private fun onChangeSpeed() {
|
||||||
|
|||||||
+7
-3
@@ -652,16 +652,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentTime() : Long {
|
fun getCurrentTrackStartOffsetMs() : Long {
|
||||||
return if (currentPlayer.mediaItemCount > 1) {
|
return if (currentPlayer.mediaItemCount > 1) {
|
||||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||||
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
||||||
currentPlayer.currentPosition + currentTrackStartOffset
|
currentTrackStartOffset
|
||||||
} else {
|
} else {
|
||||||
currentPlayer.currentPosition
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCurrentTime() : Long {
|
||||||
|
return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs()
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentTimeSeconds() : Double {
|
fun getCurrentTimeSeconds() : Double {
|
||||||
return getCurrentTime() / 1000.0
|
return getCurrentTime() / 1000.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,11 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
|
|||||||
import com.anggrayudi.storage.file.*
|
import com.anggrayudi.storage.file.*
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.LocalFolder
|
import com.audiobookshelf.app.data.LocalFolder
|
||||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.*
|
import com.getcapacitor.*
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsFileSystem")
|
@CapacitorPlugin(name = "AbsFileSystem")
|
||||||
@@ -171,26 +166,6 @@ class AbsFileSystem : Plugin() {
|
|||||||
call.resolve(jsObject)
|
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
|
@PluginMethod
|
||||||
fun removeFolder(call: PluginCall) {
|
fun removeFolder(call: PluginCall) {
|
||||||
val folderId = call.data.getString("folderId", "").toString()
|
val folderId = call.data.getString("folderId", "").toString()
|
||||||
@@ -205,27 +180,6 @@ class AbsFileSystem : Plugin() {
|
|||||||
call.resolve()
|
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
|
@PluginMethod
|
||||||
fun deleteItem(call: PluginCall) {
|
fun deleteItem(call: PluginCall) {
|
||||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<string name="app_name">audiobookshelf</string>
|
<string name="app_name">audiobookshelf</string>
|
||||||
<string name="title_activity_main">audiobookshelf</string>
|
<string name="title_activity_main">audiobookshelf</string>
|
||||||
<string name="package_name">com.audiobookshelf.app</string>
|
<string name="package_name">com.audiobookshelf.app</string>
|
||||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
<string name="custom_url_scheme">audiobookshelf</string>
|
||||||
<string name="add_widget">Add widget</string>
|
<string name="add_widget">Add widget</string>
|
||||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
<string name="action_jump_forward">Jump Forward</string>
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
|||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-browser'
|
||||||
|
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||||
|
|
||||||
include ':capacitor-clipboard'
|
include ':capacitor-clipboard'
|
||||||
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
||||||
|
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export default {
|
|||||||
this.notifyOnReady()
|
this.notifyOnReady()
|
||||||
},
|
},
|
||||||
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 (!this.isIos) return
|
||||||
|
|
||||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export default {
|
|||||||
showBookshelfListView() {
|
showBookshelfListView() {
|
||||||
return this.isBookEntity && this.bookshelfListView
|
return this.isBookEntity && this.bookshelfListView
|
||||||
},
|
},
|
||||||
|
sortingIgnorePrefix() {
|
||||||
|
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||||
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
return this.page
|
return this.page
|
||||||
},
|
},
|
||||||
@@ -364,8 +367,14 @@ export default {
|
|||||||
this.handleScroll(scrollTop)
|
this.handleScroll(scrollTop)
|
||||||
},
|
},
|
||||||
buildSearchParams() {
|
buildSearchParams() {
|
||||||
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
|
if (this.page === 'search' || this.page === 'collections') {
|
||||||
return ''
|
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()
|
let searchParams = new URLSearchParams()
|
||||||
|
|||||||
@@ -139,9 +139,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</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>
|
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||||
@@ -81,9 +81,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -187,20 +184,21 @@ export default {
|
|||||||
},
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
return this.collapsedSeries?.numBooks || 0
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||||
return this.title.substr(4) + ', The'
|
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||||
}
|
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||||
return this.title
|
|
||||||
},
|
},
|
||||||
displayAuthor() {
|
displayAuthor() {
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.collapsedSeries) return `${this.booksInSeries} books in series`
|
||||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
|
if (this.collapsedSeries) return null
|
||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||||
@@ -217,19 +215,19 @@ export default {
|
|||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
return !this.isSelectionMode && this.hasEbook
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -237,85 +235,13 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._libraryItem.isInvalid
|
return this._libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
hasMissingParts() {
|
numMissingParts() {
|
||||||
return this._libraryItem.hasMissingParts
|
if (this.isPodcast) return 0
|
||||||
},
|
return this.media.numMissingParts
|
||||||
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
|
|
||||||
},
|
},
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$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() {
|
coverWidth() {
|
||||||
return 80 / this.bookCoverAspectRatio
|
return 80 / this.bookCoverAspectRatio
|
||||||
}
|
}
|
||||||
@@ -340,7 +266,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
if (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}`)
|
else router.push(`/item/${this.libraryItemId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
||||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
<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">
|
<template v-if="!showForm">
|
||||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
|
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
|
||||||
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
|
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
|
||||||
@@ -14,23 +15,21 @@
|
|||||||
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
|
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- form to add a new server connection config -->
|
||||||
<div v-else class="w-full">
|
<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">
|
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||||
<span class="material-icons text-gray-300">arrow_back</span>
|
<span class="material-icons text-gray-300">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-lg leading-7 mb-2">Server address</h2>
|
<h2 class="text-lg leading-7 mb-2">Server address</h2>
|
||||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
||||||
<div class="flex justify-end items-center mt-6">
|
<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>
|
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<template v-if="showAuth">
|
<!-- username/password and auth methods -->
|
||||||
|
<template v-else>
|
||||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
||||||
<span class="material-icons text-gray-300">arrow_back</span>
|
<span class="material-icons text-gray-300">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +40,7 @@
|
|||||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
<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="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" />
|
<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>
|
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</template>
|
||||||
</div>
|
</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">
|
<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>
|
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||||
<p class="text-error">{{ error }}</p>
|
<p class="text-error">{{ error }}</p>
|
||||||
@@ -71,13 +73,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" />
|
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Browser } from '@capacitor/browser'
|
||||||
import { CapacitorHttp } from '@capacitor/core'
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
|
||||||
|
// TODO: when backend ready. See validateLoginFormResponse()
|
||||||
|
//const requiredServerVersion = '2.5.0'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -93,7 +101,14 @@ export default {
|
|||||||
password: null,
|
password: null,
|
||||||
error: null,
|
error: null,
|
||||||
showForm: false,
|
showForm: false,
|
||||||
showAddCustomHeaders: false
|
showAddCustomHeaders: false,
|
||||||
|
authMethods: [],
|
||||||
|
oauth: {
|
||||||
|
state: null,
|
||||||
|
verifier: null,
|
||||||
|
challenge: null,
|
||||||
|
buttonText: 'Login with OpenID'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -104,21 +119,261 @@ export default {
|
|||||||
return this.$store.state.networkConnected
|
return this.$store.state.networkConnected
|
||||||
},
|
},
|
||||||
serverConnectionConfigs() {
|
serverConnectionConfigs() {
|
||||||
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
|
return this.deviceData?.serverConnectionConfigs || []
|
||||||
},
|
},
|
||||||
lastServerConnectionConfigId() {
|
lastServerConnectionConfigId() {
|
||||||
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
|
return this.deviceData?.lastServerConnectionConfigId || null
|
||||||
},
|
},
|
||||||
lastServerConnectionConfig() {
|
lastServerConnectionConfig() {
|
||||||
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
||||||
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
||||||
},
|
},
|
||||||
numCustomHeaders() {
|
isLocalAuthEnabled() {
|
||||||
if (!this.serverConfig.customHeaders) return 0
|
return this.authMethods.includes('local') || !this.authMethods.length
|
||||||
return Object.keys(this.serverConfig.customHeaders).length
|
},
|
||||||
|
isOpenIDAuthEnabled() {
|
||||||
|
return this.authMethods.includes('openid')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
addCustomHeaders() {
|
||||||
this.showAddCustomHeaders = true
|
this.showAddCustomHeaders = true
|
||||||
},
|
},
|
||||||
@@ -207,10 +462,18 @@ export default {
|
|||||||
this.error = null
|
this.error = null
|
||||||
this.showAuth = false
|
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 {
|
try {
|
||||||
var urlObject = new URL(url)
|
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
|
if (urlObject.port) address += ':' + urlObject.port
|
||||||
return address
|
return address
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -218,18 +481,45 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 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) {
|
async getRequest(url, headers, connectTimeout = 6000) {
|
||||||
const options = {
|
const options = {
|
||||||
url,
|
url,
|
||||||
headers,
|
headers,
|
||||||
connectTimeout
|
connectTimeout
|
||||||
}
|
}
|
||||||
const response = await CapacitorHttp.get(options)
|
try {
|
||||||
console.log('[ServerConnectForm] GET request response', response)
|
const response = await CapacitorHttp.get(options)
|
||||||
if (response.status >= 400) {
|
console.log('[ServerConnectForm] GET request response', response)
|
||||||
throw new Error(response.data)
|
if (response.status == 200) {
|
||||||
} else {
|
return response
|
||||||
return response.data
|
} 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) {
|
async postRequest(url, data, headers, connectTimeout = 6000) {
|
||||||
@@ -251,10 +541,20 @@ export default {
|
|||||||
return response.data
|
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) {
|
pingServerAddress(address, customHeaders) {
|
||||||
return this.getRequest(`${address}/ping`, customHeaders)
|
return this.getRequest(`${address}/ping`, customHeaders)
|
||||||
.then((data) => {
|
.then((response) => {
|
||||||
return data.success
|
return response.data.success
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Server ping failed', error)
|
console.error('Server ping failed', error)
|
||||||
@@ -290,22 +590,175 @@ export default {
|
|||||||
async submit() {
|
async submit() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
if (!this.serverConfig.address) 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.processing = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
this.authMethods = []
|
||||||
|
|
||||||
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
|
try {
|
||||||
this.processing = false
|
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
||||||
if (success) this.showAuth = true
|
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() {
|
async submitAuth() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
@@ -329,12 +782,13 @@ export default {
|
|||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) {
|
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
console.log('Successfully logged in', JSON.stringify(user))
|
console.log('Successfully logged in', JSON.stringify(user))
|
||||||
|
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
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
|
// Set library - Use last library if set and available fallback to default user library
|
||||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||||
@@ -346,6 +800,7 @@ export default {
|
|||||||
|
|
||||||
this.serverConfig.userId = user.id
|
this.serverConfig.userId = user.id
|
||||||
this.serverConfig.token = user.token
|
this.serverConfig.token = user.token
|
||||||
|
this.serverConfig.username = user.username
|
||||||
|
|
||||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||||
|
|
||||||
@@ -380,7 +835,7 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
return authRes
|
return authRes
|
||||||
},
|
},
|
||||||
async init() {
|
init() {
|
||||||
if (this.lastServerConnectionConfig) {
|
if (this.lastServerConnectionConfig) {
|
||||||
this.connectToServer(this.lastServerConnectionConfig)
|
this.connectToServer(this.lastServerConnectionConfig)
|
||||||
} else {
|
} else {
|
||||||
@@ -389,7 +844,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.$eventBus.$on('url-open', this.appUrlOpen)
|
||||||
this.init()
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('url-open', this.appUrlOpen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / 240
|
return this.width / 240
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default {
|
|||||||
iconPack: 'material-icons',
|
iconPack: 'material-icons',
|
||||||
icon: 'podcasts',
|
icon: 'podcasts',
|
||||||
iconClass: 'text-xl',
|
iconClass: 'text-xl',
|
||||||
text: 'Search'
|
text: 'Add'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -105,30 +105,28 @@ export default {
|
|||||||
this.$nativeHttp
|
this.$nativeHttp
|
||||||
.delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
.delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark removed')
|
this.$store.commit('user/deleteBookmark', { libraryItemId: this.libraryItemId, time: bm.time })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to remove bookmark`)
|
this.$toast.error(`Failed to remove bookmark`)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
this.show = false
|
|
||||||
},
|
},
|
||||||
async clickBookmark(bm) {
|
async clickBookmark(bm) {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
this.$emit('select', bm)
|
this.$emit('select', bm)
|
||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
var bookmark = { ...updatedBookmark }
|
|
||||||
this.$nativeHttp
|
this.$nativeHttp
|
||||||
.patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.patch(`/api/me/item/${this.libraryItemId}/bookmark`, updatedBookmark)
|
||||||
.then(() => {
|
.then((bookmark) => {
|
||||||
this.$toast.success('Bookmark updated')
|
this.$store.commit('user/updateBookmark', bookmark)
|
||||||
|
this.showBookmarkTitleInput = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to update bookmark`)
|
this.$toast.error(`Failed to update bookmark`)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
this.show = false
|
|
||||||
},
|
},
|
||||||
submitCreateBookmark() {
|
submitCreateBookmark() {
|
||||||
if (!this.newBookmarkTitle) {
|
if (!this.newBookmarkTitle) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="width" height="100%">
|
<modals-modal v-model="show" :width="width" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
<div v-if="title" class="absolute top-10 left-4 z-40 pt-1 pb-1.5" style="max-width: 80%">
|
||||||
<p class="text-white text-xl truncate">{{ title }}</p>
|
<p class="text-white text-xl truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -217,7 +217,6 @@ export default {
|
|||||||
return Capacitor.convertFileSrc(this.localContentUrl)
|
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||||
}
|
}
|
||||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||||
|
|
||||||
if (this.ebookFileId) {
|
if (this.ebookFileId) {
|
||||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
<p v-if="media.duration" class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-8 min-w-8 flex justify-center">
|
<div class="w-8 min-w-8 flex justify-center">
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
<div class="w-full px-1.5 pb-1.5">
|
||||||
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex items-center w-full">
|
<div class="w-full h-full p-2 rounded-lg relative bg-bg overflow-hidden">
|
||||||
<div class="h-full relative" :style="{ width: '50px' }">
|
<nuxt-link v-if="libraryItem" :to="itemUrl" class="flex items-center w-full">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<div class="h-full relative" :style="{ width: '50px' }">
|
||||||
</div>
|
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<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>
|
<div class="item-table-content h-full px-2 flex items-center">
|
||||||
<div class="w-8 min-w-8 flex justify-center">
|
<div class="max-w-full">
|
||||||
<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">
|
<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>
|
||||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
<p v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
|
||||||
</button>
|
<p class="text-xxs text-gray-400">{{ itemDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -30,12 +38,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {}
|
||||||
isProcessingReadUpdate: false,
|
|
||||||
processingRemove: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
itemUrl() {
|
||||||
|
if (this.episodeId) return `/item/${this.libraryItem.id}/${this.episodeId}`
|
||||||
|
return `/item/${this.libraryItem.id}`
|
||||||
|
},
|
||||||
libraryItem() {
|
libraryItem() {
|
||||||
return this.item.libraryItem || {}
|
return this.item.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -57,6 +66,12 @@ export default {
|
|||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem.mediaType
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
if (this.episode) return []
|
if (this.episode) return []
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
@@ -92,12 +107,6 @@ export default {
|
|||||||
coverWidth() {
|
coverWidth() {
|
||||||
return 50
|
return 50
|
||||||
},
|
},
|
||||||
isMissing() {
|
|
||||||
return this.libraryItem.isMissing
|
|
||||||
},
|
|
||||||
isInvalid() {
|
|
||||||
return this.libraryItem.isInvalid
|
|
||||||
},
|
|
||||||
showPlayBtn() {
|
showPlayBtn() {
|
||||||
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
|
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
|
||||||
},
|
},
|
||||||
@@ -107,9 +116,31 @@ export default {
|
|||||||
},
|
},
|
||||||
streamIsPlaying() {
|
streamIsPlaying() {
|
||||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
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: {
|
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() {
|
async playClick() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.streamIsPlaying) {
|
if (this.streamIsPlaying) {
|
||||||
@@ -135,7 +166,7 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.item-table-content {
|
.item-table-content {
|
||||||
width: calc(100% - 82px);
|
width: calc(100% - 114px);
|
||||||
max-width: calc(100% - 82px);
|
max-width: calc(100% - 114px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full bg-primary bg-opacity-40">
|
<div class="w-full bg-primary/50 rounded-lg">
|
||||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
<div class="w-full h-14 flex items-center px-3">
|
||||||
<p class="pr-4">Playlist Items</p>
|
<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">
|
<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>
|
<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>
|
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="item in items">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,7 +41,11 @@ export default {
|
|||||||
return this.$elapsedPrettyExtended(this.totalDuration)
|
return this.$elapsedPrettyExtended(this.totalDuration)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
showMore(playlistItem) {
|
||||||
|
this.$emit('showMore', playlistItem)
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
|
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
||||||
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.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 */; };
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||||
@@ -99,6 +100,7 @@
|
|||||||
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
||||||
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; 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>"; };
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
||||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||||
504EC3041FED79650016851F /* Audiobookshelf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Audiobookshelf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
504EC3041FED79650016851F /* Audiobookshelf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Audiobookshelf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -302,6 +304,7 @@
|
|||||||
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
||||||
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
||||||
4D91EEC52A40F28D004807ED /* EBookFile.swift */,
|
4D91EEC52A40F28D004807ED /* EBookFile.swift */,
|
||||||
|
4DABC04E2B0139CA000F6264 /* User.swift */,
|
||||||
);
|
);
|
||||||
path = server;
|
path = server;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -542,6 +545,7 @@
|
|||||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||||
|
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||||
@@ -724,12 +728,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 23;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.66;
|
MARKETING_VERSION = 0.9.67;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -748,12 +752,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 23;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.66;
|
MARKETING_VERSION = 0.9.67;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
let configuration = Realm.Configuration(
|
let configuration = Realm.Configuration(
|
||||||
schemaVersion: 13,
|
schemaVersion: 14,
|
||||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||||
if (oldSchemaVersion < 1) {
|
if (oldSchemaVersion < 1) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<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>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
|
|||||||
@@ -31,25 +31,8 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func onReady(_ call: CAPPluginCall) {
|
@objc func onReady(_ call: CAPPluginCall) {
|
||||||
Task { await self.restorePlaybackSession() }
|
// 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()
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func startPlaybackSession(_ session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) throws {
|
@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(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(syncLocalSessionsWithServer, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(updateLocalEbookProgress, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(updateLocalEbookProgress, CAPPluginReturnPromise);
|
||||||
|
|||||||
@@ -146,17 +146,16 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
|
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
||||||
|
logger.log("syncLocalSessionsWithServer: Starting")
|
||||||
guard Store.serverConfig != nil else {
|
guard Store.serverConfig != nil else {
|
||||||
call.reject("syncLocalMediaProgressWithServer not connected to server")
|
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||||
return
|
return call.resolve()
|
||||||
}
|
}
|
||||||
ApiClient.syncMediaProgress { results in
|
|
||||||
do {
|
Task {
|
||||||
call.resolve(try results.asDictionary())
|
await ApiClient.syncLocalSessionsWithServer()
|
||||||
} catch {
|
call.resolve()
|
||||||
call.reject("Failed to report synced media progress")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ def capacitor_pods
|
|||||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||||
|
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||||
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
||||||
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
||||||
|
|||||||
+27
-21
@@ -1,23 +1,25 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Alamofire (5.6.4)
|
- Alamofire (5.6.4)
|
||||||
- Capacitor (4.8.0):
|
- Capacitor (5.4.0):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorApp (4.1.1):
|
- CapacitorApp (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorClipboard (4.1.0):
|
- CapacitorBrowser (5.1.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorCordova (4.8.0)
|
- CapacitorClipboard (5.0.6):
|
||||||
- CapacitorDialog (4.1.0):
|
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorHaptics (4.1.0):
|
- CapacitorCordova (5.4.0)
|
||||||
|
- CapacitorDialog (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorNetwork (4.1.0):
|
- CapacitorHaptics (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorPreferences (4.0.2):
|
- CapacitorNetwork (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorStatusBar (4.1.1):
|
- CapacitorPreferences (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CordovaPlugins (4.8.0):
|
- CapacitorStatusBar (5.0.6):
|
||||||
|
- Capacitor
|
||||||
|
- CordovaPlugins (5.4.0):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- Realm (10.36.0):
|
- Realm (10.36.0):
|
||||||
- Realm/Headers (= 10.36.0)
|
- Realm/Headers (= 10.36.0)
|
||||||
@@ -29,6 +31,7 @@ DEPENDENCIES:
|
|||||||
- Alamofire (~> 5.5)
|
- Alamofire (~> 5.5)
|
||||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||||
|
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||||
- "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)"
|
- "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)"
|
||||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||||
- "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)"
|
- "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)"
|
||||||
@@ -50,6 +53,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@capacitor/ios"
|
:path: "../../node_modules/@capacitor/ios"
|
||||||
CapacitorApp:
|
CapacitorApp:
|
||||||
:path: "../../node_modules/@capacitor/app"
|
:path: "../../node_modules/@capacitor/app"
|
||||||
|
CapacitorBrowser:
|
||||||
|
:path: "../../node_modules/@capacitor/browser"
|
||||||
CapacitorClipboard:
|
CapacitorClipboard:
|
||||||
:path: "../../node_modules/@capacitor/clipboard"
|
:path: "../../node_modules/@capacitor/clipboard"
|
||||||
CapacitorCordova:
|
CapacitorCordova:
|
||||||
@@ -69,19 +74,20 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
||||||
Capacitor: 6002aadd64492438e5242325025045235dcb7e84
|
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
||||||
CapacitorApp: acd42fe8561fe751ad5b5f459aa85e6acd7bee24
|
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
||||||
CapacitorClipboard: 4c092a2608520afb799429e63820256c3a59f1e5
|
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
||||||
CapacitorCordova: c6249dcb2cf04dd835c0e99df1df4b9c8ad997e2
|
CapacitorClipboard: 77edf49827ea21da2a9c05c690a4a6a4d07199c4
|
||||||
CapacitorDialog: c8a6558d29767e76a32a056bb5e0fc9104b985b0
|
CapacitorCordova: 66ce22f9976de30fd816f746e9e92e07d6befafd
|
||||||
CapacitorHaptics: 213b3a1f3efd6dbf6e6b76a1b2bb0399cf43b213
|
CapacitorDialog: 0f3c15dfe9414b83bc64aef4078f1b92bcfead26
|
||||||
CapacitorNetwork: 7126b3d2d23ca60d5ac0d8d2ecccfab0b1f305c6
|
CapacitorHaptics: 1fffc1217c7e64a472d7845be50fb0c2f7d4204c
|
||||||
CapacitorPreferences: 1d66dc32299f55ed632c5611f312878979275ea5
|
CapacitorNetwork: d80b3e79bef6ec37640ee2806c19771f07ff2d0c
|
||||||
CapacitorStatusBar: 65933e554bb5d65b361deaa936a93616086a2608
|
CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
|
||||||
CordovaPlugins: b7ac282a1681fad663e14dcbe719249f738b88ce
|
CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
|
||||||
|
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
|
||||||
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
||||||
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
||||||
|
|
||||||
PODFILE CHECKSUM: 05c80969578f3260e71d903c6ddb969847bcceb2
|
PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
|
||||||
|
|
||||||
COCOAPODS: 1.12.1
|
COCOAPODS: 1.12.1
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import RealmSwift
|
|||||||
|
|
||||||
class MediaProgress: EmbeddedObject, Codable {
|
class MediaProgress: EmbeddedObject, Codable {
|
||||||
@Persisted var id: String = ""
|
@Persisted var id: String = ""
|
||||||
|
@Persisted var userId: String = ""
|
||||||
@Persisted var libraryItemId: String = ""
|
@Persisted var libraryItemId: String = ""
|
||||||
@Persisted var episodeId: String?
|
@Persisted var episodeId: String?
|
||||||
@Persisted var duration: Double = 0
|
@Persisted var duration: Double = 0
|
||||||
@@ -23,7 +24,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
|||||||
@Persisted var finishedAt: Double?
|
@Persisted var finishedAt: Double?
|
||||||
|
|
||||||
private enum CodingKeys : String, CodingKey {
|
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() {
|
override init() {
|
||||||
@@ -33,6 +34,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
|||||||
required init(from decoder: Decoder) throws {
|
required init(from decoder: Decoder) throws {
|
||||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try values.decode(String.self, forKey: .id)
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
userId = try values.decode(String.self, forKey: .userId)
|
||||||
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
|
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
|
||||||
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||||
duration = try values.doubleOrStringDecoder(key: .duration)
|
duration = try values.doubleOrStringDecoder(key: .duration)
|
||||||
@@ -40,7 +42,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
|||||||
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
|
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
|
||||||
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
||||||
ebookLocation = try values.decodeIfPresent(String.self, forKey: .ebookLocation)
|
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)
|
lastUpdate = try values.doubleOrStringDecoder(key: .lastUpdate)
|
||||||
startedAt = try values.doubleOrStringDecoder(key: .startedAt)
|
startedAt = try values.doubleOrStringDecoder(key: .startedAt)
|
||||||
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
|
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
|
||||||
@@ -49,6 +51,7 @@ class MediaProgress: EmbeddedObject, Codable {
|
|||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try container.encode(id, forKey: .id)
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(userId, forKey: .userId)
|
||||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||||
try container.encode(episodeId, forKey: .episodeId)
|
try container.encode(episodeId, forKey: .episodeId)
|
||||||
try container.encode(duration, forKey: .duration)
|
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
|
internal var currentTrackIndex = 0
|
||||||
private var allPlayerItems:[AVPlayerItem] = []
|
private var allPlayerItems:[AVPlayerItem] = []
|
||||||
|
|
||||||
private var pausedTimer: Timer?
|
|
||||||
|
|
||||||
// MARK: - Constructor
|
// MARK: - Constructor
|
||||||
init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
||||||
self.playWhenReady = playWhenReady
|
self.playWhenReady = playWhenReady
|
||||||
@@ -159,7 +157,6 @@ class AudioPlayer: NSObject {
|
|||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
|
||||||
|
|
||||||
// Remove timers
|
// Remove timers
|
||||||
self.stopPausedTimer()
|
|
||||||
self.removeSleepTimer()
|
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
|
// MARK: - Methods
|
||||||
public func play(allowSeekBack: Bool = false, isInitializing: Bool = false) {
|
public func play(allowSeekBack: Bool = false, isInitializing: Bool = false) {
|
||||||
guard self.isInitialized() || isInitializing else { return }
|
guard self.isInitialized() || isInitializing else { return }
|
||||||
@@ -345,9 +327,6 @@ class AudioPlayer: NSObject {
|
|||||||
private func resumePlayback() {
|
private func resumePlayback() {
|
||||||
logger.log("PLAY: Resuming playback")
|
logger.log("PLAY: Resuming playback")
|
||||||
|
|
||||||
// Stop the paused timer
|
|
||||||
self.stopPausedTimer()
|
|
||||||
|
|
||||||
self.markAudioSessionAs(active: true)
|
self.markAudioSessionAs(active: true)
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
self.audioPlayer.play()
|
self.audioPlayer.play()
|
||||||
@@ -380,8 +359,6 @@ class AudioPlayer: NSObject {
|
|||||||
|
|
||||||
self.status = .paused
|
self.status = .paused
|
||||||
updateNowPlaying()
|
updateNowPlaying()
|
||||||
|
|
||||||
self.startPausedTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func seek(_ to: Double, from: String) {
|
public func seek(_ to: Double, from: String) {
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ extension AudioPlayer {
|
|||||||
// MARK: - Internal helpers
|
// MARK: - Internal helpers
|
||||||
|
|
||||||
internal func handleTrackChangeForChapterSleepTimer() {
|
internal func handleTrackChangeForChapterSleepTimer() {
|
||||||
// If no sleep timer is set, this does nothing
|
if self.isChapterSleepTimerSet() {
|
||||||
self.setChapterSleepTimer(stopAt: self.sleepTimeChapterStopAt)
|
self.setChapterSleepTimer(stopAt: self.sleepTimeChapterStopAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decrementSleepTimerIfRunning() {
|
private func decrementSleepTimerIfRunning() {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class PlayerHandler {
|
|||||||
|
|
||||||
// Cleanup and sync old sessions
|
// Cleanup and sync old sessions
|
||||||
cleanupOldSessions(currentSessionId: sessionId)
|
cleanupOldSessions(currentSessionId: sessionId)
|
||||||
Task { await PlayerProgress.shared.syncToServer() }
|
|
||||||
|
|
||||||
// Set now playing info
|
// 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))
|
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)
|
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
|
// MARK: - SYNC LOGIC
|
||||||
|
|
||||||
@@ -175,6 +153,7 @@ class PlayerProgress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Unused for now
|
||||||
private func updateLocalSessionFromServerMediaProgress() async throws {
|
private func updateLocalSessionFromServerMediaProgress() async throws {
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
|
logger.log("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
|
||||||
guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: {
|
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)
|
return await postResource(endpoint: "api/session/local", parameters: session)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) {
|
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions))
|
||||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
}
|
||||||
}.map { $0.freeze() }
|
|
||||||
|
|
||||||
if ( !localMediaProgressList.isEmpty ) {
|
public static func syncLocalSessionsWithServer() async {
|
||||||
let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList)
|
do {
|
||||||
logger.log("Sending sync local progress request with \(localMediaProgressList.count) progress items")
|
// Sync server progress with local media progress
|
||||||
postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in
|
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||||
let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count)
|
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
logger.log("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))")
|
}.map { $0.freeze() }
|
||||||
|
logger.log("syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server")
|
||||||
|
|
||||||
if let updates = response.localProgressUpdates {
|
if (localMediaProgressList.isEmpty) {
|
||||||
for update in updates {
|
logger.log("syncLocalSessionsWithServer: No local progress to sync")
|
||||||
do {
|
} else {
|
||||||
try update.save()
|
let currentUser = await ApiClient.getCurrentUser()
|
||||||
} catch {
|
guard let currentUser = currentUser else {
|
||||||
debugPrint("Failed to update local media progress")
|
logger.log("syncLocalSessionsWithServer: No User")
|
||||||
debugPrint(error)
|
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 {
|
} catch {
|
||||||
logger.log("No local media progress to sync")
|
debugPrint(error)
|
||||||
callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0))
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +266,11 @@ class ApiClient {
|
|||||||
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
|
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) {
|
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
||||||
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
||||||
if episodeId != nil {
|
if episodeId != nil {
|
||||||
@@ -241,9 +278,10 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in
|
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in
|
||||||
callback(obj)
|
callback(obj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func pingServer() async -> Bool {
|
public static func pingServer() async -> Bool {
|
||||||
var status = true
|
var status = true
|
||||||
AF.request("\(Store.serverConfig!.address)/ping", method: .get).responseDecodable(of: PingResponsePayload.self) { response in
|
AF.request("\(Store.serverConfig!.address)/ping", method: .get).responseDecodable(of: PingResponsePayload.self) { response in
|
||||||
@@ -287,6 +325,10 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
|
|||||||
var numLocalProgressUpdates: Int?
|
var numLocalProgressUpdates: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
||||||
|
var sessions: [PlaybackSession]
|
||||||
|
}
|
||||||
|
|
||||||
struct Connectivity {
|
struct Connectivity {
|
||||||
static private let sharedInstance = NetworkReachabilityManager()!
|
static private let sharedInstance = NetworkReachabilityManager()!
|
||||||
static var isConnectedToInternet:Bool {
|
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? {
|
public func getPlaybackSession(id: String) -> PlaybackSession? {
|
||||||
do {
|
do {
|
||||||
let realm = try Realm()
|
let realm = try Realm()
|
||||||
|
|||||||
+4
-43
@@ -44,12 +44,7 @@ export default {
|
|||||||
if (timeSinceDisconnect > 5000) {
|
if (timeSinceDisconnect > 5000) {
|
||||||
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.$platform === 'ios') {
|
this.syncLocalSessions()
|
||||||
// TODO: Update ios to not use this
|
|
||||||
this.syncLocalMediaProgress()
|
|
||||||
} else {
|
|
||||||
this.syncLocalSessions()
|
|
||||||
}
|
|
||||||
}, 4000)
|
}, 4000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,8 +147,9 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user, userDefaultLibraryId, serverSettings } = authRes
|
const { user, userDefaultLibraryId, serverSettings, ereaderDevices } = authRes
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
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
|
// Set library - Use last library if set and available fallback to default user library
|
||||||
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||||
@@ -214,36 +210,6 @@ export default {
|
|||||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
userUpdated(user) {
|
userUpdated(user) {
|
||||||
// console.log('[default] userUpdated:', JSON.stringify(user))
|
// console.log('[default] userUpdated:', JSON.stringify(user))
|
||||||
if (this.user && this.user.id == user.id) {
|
if (this.user && this.user.id == user.id) {
|
||||||
@@ -361,12 +327,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
||||||
if (this.$platform === 'ios') {
|
await this.syncLocalSessions()
|
||||||
// TODO: Update ios to not use this
|
|
||||||
await this.syncLocalMediaProgress()
|
|
||||||
} else {
|
|
||||||
await this.syncLocalSessions()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadSavedSettings()
|
this.loadSavedSettings()
|
||||||
this.hasMounted = true
|
this.hasMounted = true
|
||||||
|
|||||||
Generated
+18
-3
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.66-beta",
|
"version": "0.9.67-beta",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.65-beta",
|
"version": "0.9.67-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^5.0.0",
|
"@capacitor/android": "^5.0.0",
|
||||||
"@capacitor/app": "^5.0.0",
|
"@capacitor/app": "^5.0.6",
|
||||||
|
"@capacitor/browser": "^5.1.0",
|
||||||
"@capacitor/clipboard": "^5.0.0",
|
"@capacitor/clipboard": "^5.0.0",
|
||||||
"@capacitor/core": "^5.0.0",
|
"@capacitor/core": "^5.0.0",
|
||||||
"@capacitor/dialog": "^5.0.0",
|
"@capacitor/dialog": "^5.0.0",
|
||||||
@@ -1954,6 +1955,14 @@
|
|||||||
"@capacitor/core": "^5.0.0"
|
"@capacitor/core": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/browser": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-7togqchk2Tvq4SmLaWhcrd4x48ES/GEZsceM+29aun7WhxQEVcDU0cJsVdSU2LNFwNhWgPV2GW90etVd1B3OdQ==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
||||||
@@ -21081,6 +21090,12 @@
|
|||||||
"integrity": "sha512-6ZXVdnNmaYILasC/RjQw+yfTmq2ZO7Q3v5lFcDVfq3PFGnybyYQh+RstBrYri+376OmXOXxBD7E6UxBhrMzXGA==",
|
"integrity": "sha512-6ZXVdnNmaYILasC/RjQw+yfTmq2ZO7Q3v5lFcDVfq3PFGnybyYQh+RstBrYri+376OmXOXxBD7E6UxBhrMzXGA==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"@capacitor/browser": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-7togqchk2Tvq4SmLaWhcrd4x48ES/GEZsceM+29aun7WhxQEVcDU0cJsVdSU2LNFwNhWgPV2GW90etVd1B3OdQ==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"@capacitor/cli": {
|
"@capacitor/cli": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.66-beta",
|
"version": "0.9.67-beta",
|
||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^5.0.0",
|
"@capacitor/android": "^5.0.0",
|
||||||
"@capacitor/app": "^5.0.0",
|
"@capacitor/app": "^5.0.6",
|
||||||
|
"@capacitor/browser": "^5.1.0",
|
||||||
"@capacitor/clipboard": "^5.0.0",
|
"@capacitor/clipboard": "^5.0.0",
|
||||||
"@capacitor/core": "^5.0.0",
|
"@capacitor/core": "^5.0.0",
|
||||||
"@capacitor/dialog": "^5.0.0",
|
"@capacitor/dialog": "^5.0.0",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ params, app, store, redirect }) {
|
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)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default {
|
|||||||
return redirect(`/connect?redirect=${route.path}`)
|
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)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="hidden absolute short:block top-1.5 left-12 p-2text-xl">audiobookshelf</p>
|
<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" />
|
<connection-server-connect-form v-if="deviceData" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default {
|
|||||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||||
console.log('Got lli', libraryItemId)
|
console.log('Got lli', libraryItemId)
|
||||||
} else if (store.state.user.serverConnectionConfig) {
|
} 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)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
+71
-232
@@ -27,9 +27,26 @@
|
|||||||
<p v-if="subtitle" class="text-gray-100 text-base">{{ subtitle }}</p>
|
<p v-if="subtitle" class="text-gray-100 text-base">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
|
<div v-if="hasLocal" class="mx-1">
|
||||||
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success text-center py-1 uppercase tracking-widest">connected</p>
|
<div v-if="isLocalOnly" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||||
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 text-center py-1">{{ libraryItem.serverAddress }}</p>
|
<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 -->
|
<!-- action buttons -->
|
||||||
<div class="col-span-full">
|
<div class="col-span-full">
|
||||||
@@ -50,7 +67,7 @@
|
|||||||
</ui-btn>
|
</ui-btn>
|
||||||
</div>
|
</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>Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
<p v-if="!useEBookProgress && !userIsFinished" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</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>
|
<p v-else-if="userIsFinished" class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||||
@@ -126,14 +143,16 @@
|
|||||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
|
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
|
||||||
|
|
||||||
<!-- modals -->
|
<!-- 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-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" />
|
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -149,8 +168,12 @@ export default {
|
|||||||
if (libraryItemId.startsWith('local')) {
|
if (libraryItemId.startsWith('local')) {
|
||||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||||
console.log('Got lli', 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) {
|
} 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)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -175,11 +198,9 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
resettingProgress: false,
|
processing: false,
|
||||||
isProcessingReadUpdate: false,
|
|
||||||
showSelectLocalFolder: false,
|
showSelectLocalFolder: false,
|
||||||
showMoreMenu: false,
|
showMoreMenu: false,
|
||||||
showDetailsModal: false,
|
|
||||||
showFullscreenCover: false,
|
showFullscreenCover: false,
|
||||||
coverRgb: 'rgb(55, 56, 56)',
|
coverRgb: 'rgb(55, 56, 56)',
|
||||||
coverBgIsLight: false,
|
coverBgIsLight: false,
|
||||||
@@ -201,6 +222,10 @@ export default {
|
|||||||
isLocal() {
|
isLocal() {
|
||||||
return this.libraryItem.isLocal
|
return this.libraryItem.isLocal
|
||||||
},
|
},
|
||||||
|
isLocalOnly() {
|
||||||
|
// TODO: Remove the possibility to have local only on android
|
||||||
|
return this.isLocal && !this.libraryItem.libraryItemId
|
||||||
|
},
|
||||||
hasLocal() {
|
hasLocal() {
|
||||||
// Server library item has matching local library item
|
// Server library item has matching local library item
|
||||||
return this.isLocal || this.libraryItem.localLibraryItem
|
return this.isLocal || this.libraryItem.localLibraryItem
|
||||||
@@ -221,11 +246,41 @@ export default {
|
|||||||
if (!this.isLocal) return this.libraryItem.id
|
if (!this.isLocal) return this.libraryItem.id
|
||||||
// Check if local library item is connected to the current server
|
// Check if local library item is connected to the current server
|
||||||
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
|
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 this.libraryItem.libraryItemId
|
||||||
}
|
}
|
||||||
return null
|
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() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
@@ -311,7 +366,7 @@ export default {
|
|||||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||||
},
|
},
|
||||||
userIsFinished() {
|
userIsFinished() {
|
||||||
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
|
return !!this.userItemProgress?.isFinished
|
||||||
},
|
},
|
||||||
userTimeRemaining() {
|
userTimeRemaining() {
|
||||||
if (!this.userItemProgress) return 0
|
if (!this.userItemProgress) return 0
|
||||||
@@ -324,10 +379,10 @@ export default {
|
|||||||
},
|
},
|
||||||
progressPercent() {
|
progressPercent() {
|
||||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userItemProgress.ebookProgress), 0)
|
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() {
|
userProgressFinishedAt() {
|
||||||
return this.userItemProgress ? this.userItemProgress.finishedAt : 0
|
return this.userItemProgress?.finishedAt || 0
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.isPlaying && !this.$store.getters['getIsCurrentSessionLocal']
|
return this.isPlaying && !this.$store.getters['getIsCurrentSessionLocal']
|
||||||
@@ -387,83 +442,6 @@ export default {
|
|||||||
isCasting() {
|
isCasting() {
|
||||||
return this.$store.state.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() {
|
coverWidth() {
|
||||||
let width = this.windowWidth - 94
|
let width = this.windowWidth - 94
|
||||||
if (width > 325) return 325
|
if (width > 325) return 325
|
||||||
@@ -471,42 +449,9 @@ export default {
|
|||||||
|
|
||||||
if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio
|
if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio
|
||||||
return width
|
return width
|
||||||
},
|
|
||||||
mediaId() {
|
|
||||||
if (this.isPodcast) return null
|
|
||||||
return this.serverLibraryItemId || this.localLibraryItemId
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
|
||||||
@@ -521,39 +466,6 @@ export default {
|
|||||||
console.log(e)
|
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() {
|
moreButtonPress() {
|
||||||
this.showMoreMenu = true
|
this.showMoreMenu = true
|
||||||
},
|
},
|
||||||
@@ -635,37 +547,6 @@ export default {
|
|||||||
this.$eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.serverLibraryItemId, startTime })
|
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.$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.resettingProgress = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemUpdated(libraryItem) {
|
itemUpdated(libraryItem) {
|
||||||
if (libraryItem.id === this.serverLibraryItemId) {
|
if (libraryItem.id === this.serverLibraryItemId) {
|
||||||
console.log('Item Updated')
|
console.log('Item Updated')
|
||||||
@@ -767,48 +648,6 @@ export default {
|
|||||||
this.$set(this.libraryItem, 'localLibraryItem', item)
|
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.$nativeHttp
|
|
||||||
.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) {
|
libraryChanged(libraryId) {
|
||||||
if (this.libraryItem.libraryId !== libraryId) {
|
if (this.libraryItem.libraryId !== libraryId) {
|
||||||
this.$router.replace('/bookshelf')
|
this.$router.replace('/bookshelf')
|
||||||
|
|||||||
@@ -11,10 +11,7 @@
|
|||||||
|
|
||||||
<p class="mb-2 text-base text-white">Local Library Items ({{ localLibraryItems.length }})</p>
|
<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">
|
<div class="w-full media-item-container overflow-y-auto">
|
||||||
<p>Scanning...</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="w-full media-item-container overflow-y-auto">
|
|
||||||
<template v-for="localLibraryItem in localLibraryItems">
|
<template v-for="localLibraryItem in localLibraryItems">
|
||||||
<nuxt-link :to="`/localMedia/item/${localLibraryItem.id}`" :key="localLibraryItem.id" class="flex my-1">
|
<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">
|
<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 {
|
export default {
|
||||||
asyncData({ params, query }) {
|
asyncData({ params, query }) {
|
||||||
return {
|
return {
|
||||||
folderId: params.id,
|
folderId: params.id
|
||||||
shouldScan: !!query.scan
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localLibraryItems: [],
|
localLibraryItems: [],
|
||||||
folder: null,
|
folder: null,
|
||||||
isScanning: false,
|
|
||||||
removingFolder: false,
|
removingFolder: false,
|
||||||
showDialog: false
|
showDialog: false
|
||||||
}
|
}
|
||||||
@@ -68,20 +63,7 @@ export default {
|
|||||||
},
|
},
|
||||||
dialogItems() {
|
dialogItems() {
|
||||||
if (this.isInternalStorage) return []
|
if (this.isInternalStorage) return []
|
||||||
const items = [
|
const items = []
|
||||||
{
|
|
||||||
text: 'Scan',
|
|
||||||
value: 'scan'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
if (this.localLibraryItems.length) {
|
|
||||||
items.push({
|
|
||||||
text: 'Force Re-Scan',
|
|
||||||
value: 'rescan'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
text: 'Remove',
|
text: 'Remove',
|
||||||
value: 'remove'
|
value: 'remove'
|
||||||
@@ -107,11 +89,7 @@ export default {
|
|||||||
},
|
},
|
||||||
dialogAction(action) {
|
dialogAction(action) {
|
||||||
console.log('Dialog action', action)
|
console.log('Dialog action', action)
|
||||||
if (action == 'scan') {
|
if (action == 'remove') {
|
||||||
this.scanFolder()
|
|
||||||
} else if (action == 'rescan') {
|
|
||||||
this.scanFolder(true)
|
|
||||||
} else if (action == 'remove') {
|
|
||||||
this.removeFolder()
|
this.removeFolder()
|
||||||
}
|
}
|
||||||
this.showDialog = false
|
this.showDialog = false
|
||||||
@@ -135,37 +113,6 @@ export default {
|
|||||||
play(mediaItem) {
|
play(mediaItem) {
|
||||||
this.$eventBus.$emit('play-item', { libraryItemId: mediaItem.id })
|
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() {
|
async init() {
|
||||||
var folder = await this.$db.getLocalFolder(this.folderId)
|
var folder = await this.$db.getLocalFolder(this.folderId)
|
||||||
this.folder = folder
|
this.folder = folder
|
||||||
@@ -179,10 +126,6 @@ export default {
|
|||||||
coverPathSrc: lmi.coverContentUrl ? Capacitor.convertFileSrc(lmi.coverContentUrl) : null
|
coverPathSrc: lmi.coverContentUrl ? Capacitor.convertFileSrc(lmi.coverContentUrl) : null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.shouldScan) {
|
|
||||||
this.scanFolder()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
newLocalLibraryItem(item) {
|
newLocalLibraryItem(item) {
|
||||||
if (item.folderId == this.folderId) {
|
if (item.folderId == this.folderId) {
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$toast.success('Folder permission success')
|
this.$toast.success('Folder permission success')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
|
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
const androidSdkVersion = await this.$getAndroidSDKVersion()
|
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>
|
<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">
|
<div class="w-full max-w-full media-item-container overflow-y-auto overflow-x-hidden relative pb-4" :class="{ 'media-order-changed': orderChanged }">
|
||||||
<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 v-if="!isPodcast && audioTracksCopy.length" class="w-full py-2">
|
<div v-if="!isPodcast && audioTracksCopy.length" class="w-full py-2">
|
||||||
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
|
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
|
||||||
|
|
||||||
@@ -148,7 +145,6 @@ export default {
|
|||||||
removingItem: false,
|
removingItem: false,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
folder: null,
|
folder: null,
|
||||||
isScanning: false,
|
|
||||||
showDialog: false,
|
showDialog: false,
|
||||||
selectedAudioTrack: null,
|
selectedAudioTrack: null,
|
||||||
selectedEpisode: null,
|
selectedEpisode: null,
|
||||||
@@ -229,19 +225,17 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
var options = []
|
return [
|
||||||
if (!this.isIos && !this.isInternalStorage && !this.libraryItemId) {
|
{
|
||||||
options.push({ text: 'Scan', value: 'scan' })
|
text: 'Delete local item',
|
||||||
options.push({ text: 'Force Re-Scan', value: 'rescan' })
|
value: 'delete'
|
||||||
}
|
}
|
||||||
options.push({ text: 'Delete local item', value: 'delete' })
|
]
|
||||||
return options
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
draggableUpdate() {
|
draggableUpdate() {
|
||||||
console.log('Draggable Update')
|
|
||||||
for (let i = 0; i < this.audioTracksCopy.length; i++) {
|
for (let i = 0; i < this.audioTracksCopy.length; i++) {
|
||||||
var trackCopy = this.audioTracksCopy[i]
|
var trackCopy = this.audioTracksCopy[i]
|
||||||
var track = this.audioTracks[i]
|
var track = this.audioTracks[i]
|
||||||
@@ -295,11 +289,7 @@ export default {
|
|||||||
console.log('Dialog action', action)
|
console.log('Dialog action', action)
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
if (action == 'scan') {
|
if (action == 'delete') {
|
||||||
this.scanItem()
|
|
||||||
} else if (action == 'rescan') {
|
|
||||||
this.scanItem(true)
|
|
||||||
} else if (action == 'delete') {
|
|
||||||
this.deleteItem()
|
this.deleteItem()
|
||||||
} else if (action == 'track-delete') {
|
} else if (action == 'track-delete') {
|
||||||
if (this.isPodcast) this.deleteEpisode()
|
if (this.isPodcast) this.deleteEpisode()
|
||||||
@@ -377,25 +367,6 @@ export default {
|
|||||||
} else this.$toast.error('Failed to delete')
|
} 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() {
|
async init() {
|
||||||
this.localLibraryItem = await this.$db.getLocalLibraryItem(this.localLibraryItemId)
|
this.localLibraryItem = await this.$db.getLocalLibraryItem(this.localLibraryItemId)
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<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">
|
<div class="w-full flex justify-center">
|
||||||
<covers-playlist-cover :items="playlistItems" :width="180" :height="180" />
|
<covers-playlist-cover :items="playlistItems" :width="180" :height="180" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow py-6">
|
<div class="flex-grow px-1 py-6">
|
||||||
<div class="flex items-center px-2">
|
<div class="flex items-center px-3">
|
||||||
<h1 class="text-xl font-sans">
|
<h1 class="text-xl font-sans">
|
||||||
{{ playlistName }}
|
{{ playlistName }}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -16,13 +16,18 @@
|
|||||||
</ui-btn>
|
</ui-btn>
|
||||||
</div>
|
</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>
|
<p class="text-base text-gray-100">{{ description }}</p>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ export default {
|
|||||||
return redirect(`/connect?redirect=${route.path}`)
|
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)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -67,7 +72,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showMoreMenu: false,
|
||||||
|
processing: false,
|
||||||
|
selectedLibraryItem: null,
|
||||||
|
selectedEpisode: null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
@@ -101,6 +111,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
showMore(playlistItem) {
|
||||||
|
this.selectedLibraryItem = playlistItem.libraryItem
|
||||||
|
this.selectedEpisode = playlistItem.episode
|
||||||
|
this.showMoreMenu = true
|
||||||
|
},
|
||||||
clickPlay() {
|
clickPlay() {
|
||||||
const nextItem = this.playableItems.find((i) => {
|
const nextItem = this.playableItems.find((i) => {
|
||||||
const prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId)
|
const prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId)
|
||||||
|
|||||||
+14
-2
@@ -54,6 +54,15 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,7 +79,8 @@ export default {
|
|||||||
podcastResults: [],
|
podcastResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
authorResults: [],
|
authorResults: [],
|
||||||
narratorResults: []
|
narratorResults: [],
|
||||||
|
tagResults: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -81,7 +91,7 @@ export default {
|
|||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
totalResults() {
|
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: {
|
methods: {
|
||||||
@@ -97,6 +107,7 @@ export default {
|
|||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.narratorResults = []
|
this.narratorResults = []
|
||||||
|
this.tagResults = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isFetching = true
|
this.isFetching = true
|
||||||
@@ -117,6 +128,7 @@ export default {
|
|||||||
this.seriesResults = results?.series || []
|
this.seriesResults = results?.series || []
|
||||||
this.authorResults = results?.authors || []
|
this.authorResults = results?.authors || []
|
||||||
this.narratorResults = results?.narrators || []
|
this.narratorResults = results?.narrators || []
|
||||||
|
this.tagResults = results?.tags || []
|
||||||
},
|
},
|
||||||
updateSearch(val) {
|
updateSearch(val) {
|
||||||
clearTimeout(this.searchTimeout)
|
clearTimeout(this.searchTimeout)
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
value: [{
|
value: [{
|
||||||
id: 'local_test',
|
id: 'local_test',
|
||||||
libraryItemId: 'test34',
|
libraryItemId: 'test34',
|
||||||
|
serverAddress: 'https://abs.test.com',
|
||||||
|
serverUserId: 'test56',
|
||||||
folderId: 'test1',
|
folderId: 'test1',
|
||||||
absolutePath: 'a',
|
absolutePath: 'a',
|
||||||
contentUrl: 'c',
|
contentUrl: 'c',
|
||||||
@@ -194,10 +196,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncLocalMediaProgressWithServer() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncLocalSessionsWithServer() {
|
async syncLocalSessionsWithServer() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,6 @@ class DbService {
|
|||||||
return AbsDatabase.removeLocalMediaProgress({ localMediaProgressId })
|
return AbsDatabase.removeLocalMediaProgress({ localMediaProgressId })
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLocalMediaProgressWithServer() {
|
|
||||||
return AbsDatabase.syncLocalMediaProgressWithServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLocalSessionsWithServer() {
|
syncLocalSessionsWithServer() {
|
||||||
return AbsDatabase.syncLocalSessionsWithServer()
|
return AbsDatabase.syncLocalSessionsWithServer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,6 +282,14 @@ export default ({ store, app }, inject) => {
|
|||||||
window.history.back()
|
window.history.back()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://capacitorjs.com/docs/apis/app#addlistenerappurlopen-
|
||||||
|
* Listen for url open events for the app. This handles both custom URL scheme links as well as URLs your app handles
|
||||||
|
*/
|
||||||
|
App.addListener('appUrlOpen', (data) => {
|
||||||
|
eventBus.$emit('url-open', data.url)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
+5
-1
@@ -7,7 +7,8 @@ export const state = () => ({
|
|||||||
showModal: false,
|
showModal: false,
|
||||||
issues: 0,
|
issues: 0,
|
||||||
filterData: null,
|
filterData: null,
|
||||||
numUserPlaylists: 0
|
numUserPlaylists: 0,
|
||||||
|
ereaderDevices: []
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -177,5 +178,8 @@ export const mutations = {
|
|||||||
if (genre && !state.filterData.genres.includes(genre)) state.filterData.genres.push(genre)
|
if (genre && !state.filterData.genres.includes(genre)) state.filterData.genres.push(genre)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
setEReaderDevices(state, ereaderDevices) {
|
||||||
|
state.ereaderDevices = ereaderDevices
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,5 +145,21 @@ export const mutations = {
|
|||||||
setSettings(state, settings) {
|
setSettings(state, settings) {
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
state.settings = settings
|
state.settings = settings
|
||||||
|
},
|
||||||
|
updateBookmark(state, bookmark) {
|
||||||
|
if (!state.user?.bookmarks) return
|
||||||
|
state.user.bookmarks = state.user.bookmarks.map((bm) => {
|
||||||
|
if (bm.libraryItemId === bookmark.libraryItemId && bm.time === bookmark.time) {
|
||||||
|
return bookmark
|
||||||
|
}
|
||||||
|
return bm
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteBookmark(state, { libraryItemId, time }) {
|
||||||
|
if (!state.user?.bookmarks) return
|
||||||
|
state.user.bookmarks = state.user.bookmarks.filter(bm => {
|
||||||
|
if (bm.libraryItemId === libraryItemId && bm.time === time) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user