Compare commits

...
Author SHA1 Message Date
advplyr 0c49bcfe3f iOS version bump 0.9.44 2022-05-06 20:04:42 -05:00
advplyr aecb6a8dd2 Android version bump 0.9.44-beta 2022-05-06 20:01:01 -05:00
advplyr 718947522e Fix:Remove download notifications after download item finished #161 2022-05-06 19:45:07 -05:00
advplyr 2f8ca51447 Add:Vibrate when sleep timer is extended or reset by shaking #169 2022-05-06 19:28:07 -05:00
advplyr 4fa6dd2616 Fix:Cover crashing when using in media notification. Convert content uri for local image to File and pass main activity context instead of player service context #35 #164 2022-05-06 18:57:05 -05:00
advplyr 736e57fafd Add:Fallback to transcode when direct play fails, and send playback failed event to client 2022-05-06 18:17:45 -05:00
advplyr 30d86279a5 Update:Host fonts locally 2022-05-05 18:25:32 -05:00
advplyrandGitHub 8bbfcdeb82 Merge pull request #166 from rasmuslos/master
Added sleep timer
2022-05-05 07:49:44 -05:00
Rasmus Krämer d23cf62264 Fix sleep timer increase & decrease 2022-05-05 14:38:34 +02:00
advplyr 73d5b19d2b Update:Casting when playing local item if connected to server linked to local item #170, Update:Play button color when playing local item verse server item, Add:Show play method at the top of audio player (Direct, Local, Transcode) 2022-05-04 19:31:56 -05:00
Rasmus Krämer 1959351125 Removed unnecessary comment 2022-05-03 15:03:53 +02:00
Rasmus Krämer 0708133779 Fixed crash 2022-05-03 15:01:30 +02:00
Rasmus Krämer 9a81fc3688 Merge branch 'advplyr-master' 2022-05-03 14:42:20 +02:00
Rasmus Krämer ac71d39265 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app into advplyr-master 2022-05-03 14:41:46 +02:00
Rasmus Krämer 4203654ec8 Added sleep timer 2022-05-03 14:32:46 +02:00
Rasmus Krämer 9701c767b2 Small improvements 2022-05-03 12:55:13 +02:00
advplyrandGitHub 0223df4f9e Merge pull request #162 from benonymity/ListViewImprovements
Tweaks for bookshelf list view
2022-05-01 18:34:48 -05:00
benonymity 7549385404 Return null if not podcast 2022-05-01 18:27:02 -04:00
benonymity 3f2d0ed8b1 Display number of podcast episodes in list view 2022-05-01 18:03:37 -04:00
benonymity e4a5927e07 Tweaks for bookshelf list view 2022-05-01 16:49:56 -04:00
advplyr a3aac4da75 Update: iOS AudioPlayer direct play with multi-track using AVQueuePlayer 2022-05-01 12:19:31 -05:00
advplyr 1e9453e501 Fix:Check google services are available before initializing chromecast #158 2022-04-30 16:33:41 -05:00
advplyr bec3f5841e Fix:Setting local library item as finished when local media progress does not exist 2022-04-30 15:35:20 -05:00
advplyrandGitHub 068762912f Merge pull request #134 from benonymity/MarkAsRead
Add mark as read button
2022-04-30 14:58:45 -05:00
advplyr 01e85d0e91 Fix:Android replace reserved characters in filenames #154 2022-04-30 14:45:55 -05:00
benonymity a67c19f30f Shorten play button text 2022-04-30 15:01:27 -04:00
Rasmus Krämer 394363c8cb Send report when playback ends 2022-04-30 10:58:08 +02:00
benonymity 7fd51ebcc1 Fix button padding, final tests 2022-04-28 20:05:25 -04:00
benonymity ae4678cf24 Resize button, no podcast downloads on iOS 2022-04-28 17:13:21 -04:00
benonymity 68e565ebe2 Don't show if a podcast 2022-04-28 16:38:27 -04:00
benonymity d99f4406b7 Removed episode reference, smaller play button 2022-04-28 13:21:47 -04:00
BenandGitHub 2064cd8380 Merge branch 'advplyr:master' into MarkAsRead 2022-04-28 12:24:44 -04:00
benonymity 415ff65561 Display when local, add CSS classes 2022-04-27 15:19:29 -04:00
benonymity 1fed00ca81 Add localLIbraryProgress updates 2022-04-24 22:02:22 -04:00
BenandGitHub a63022a669 Merge branch 'advplyr:master' into MarkAsRead 2022-04-24 21:40:01 -04:00
benonymity 114dbd24bc Add button to mark book as read 2022-04-23 00:27:03 -04:00
38 changed files with 1240 additions and 323 deletions
+2 -2
View File
@@ -29,8 +29,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 72
versionName "0.9.43-beta"
versionCode 73
versionName "0.9.44-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -5,6 +5,9 @@ import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.MediaControllerCompat
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.toRawFile
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -60,10 +63,25 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
// Block on downloading artwork.
val context = playerNotificationService.getContext()
// Fix attempt for #35 local cover crashing
// Convert content uri to a file and pass to Glide
var urival:Any = uri
if (uri.toString().startsWith("content:")) {
val imageDocFile = DocumentFile.fromSingleUri(context, uri)
Log.d(tag, "Converting local content url $uri to file with path ${imageDocFile?.getAbsolutePath(context)}")
val file = imageDocFile?.toRawFile(context)
file?.let {
Log.d(tag, "Using local file image instead of content uri ${it.absolutePath}")
urival = it
}
}
try {
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
Glide.with(context).applyDefaultRequestOptions(glideOptions)
.asBitmap()
.load(uri)
.load(urival)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
@@ -71,7 +89,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
} catch (e: Exception) {
e.printStackTrace()
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
Glide.with(context).applyDefaultRequestOptions(glideOptions)
.asBitmap()
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
@@ -539,6 +539,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
}
fun getContext():Context {
return ctx
}
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
@@ -1,7 +1,7 @@
package com.audiobookshelf.app.player
import android.os.Handler
import android.os.Looper
import android.content.Context
import android.os.*
import android.util.Log
import java.util.*
import kotlin.concurrent.schedule
@@ -9,9 +9,8 @@ import kotlin.math.roundToInt
const val SLEEP_EXTENSION_TIME = 900000L // 15m
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) {
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
private val tag = "SleepTimerManager"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private var sleepTimerTask:TimerTask? = null
private var sleepTimerRunning:Boolean = false
@@ -64,7 +63,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
// Register shake sensor
playerNotificationService.registerSensor()
var currentTime = getCurrentTime()
val currentTime = getCurrentTime()
if (isChapterTime) {
if (currentTime > time) {
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
@@ -95,7 +94,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (getIsPlaying()) {
sleepTimerElapsed += 1000L
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
if (sleepTimeSecondsRemaining > 0) {
@@ -111,7 +110,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) {
// Start fading out audio
var volume = sleepTimeSecondsRemaining / 30F
val volume = sleepTimeSecondsRemaining / 30F
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume)
}
@@ -129,7 +128,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
playerNotificationService.unregisterSensor()
}
fun getSleepTimerTime():Long? {
fun getSleepTimerTime():Long {
return sleepTimerEndTime
}
@@ -139,6 +138,30 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
}
// Vibrate when extending sleep timer by shaking
private fun vibrate() {
val context = playerNotificationService.getContext()
val vibrator:Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibrator = vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
vibrator.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
it.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
it.vibrate(10)
}
}
}
private fun extendSleepTime() {
if (!sleepTimerRunning) return
setVolume(1F)
@@ -157,7 +180,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (!sleepTimerRunning) {
if (sleepTimerFinishedAt <= 0L) return
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
{
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
@@ -165,14 +188,18 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
return
}
var newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
vibrate()
setSleepTimer(newSleepTime, false)
play()
return
}
// Only extend if within 30 seconds of finishing
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
if (sleepTimeRemaining <= 30) extendSleepTime()
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
if (sleepTimeRemaining <= 30) {
vibrate()
extendSleepTime()
}
}
fun handleShake() {
@@ -188,7 +215,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerLength += time
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else {
var newSleepEndTime = sleepTimerEndTime + time
val newSleepEndTime = sleepTimerEndTime + time
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
getDuration()
} else {
@@ -209,7 +236,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerLength -= time
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
} else {
var newSleepEndTime = sleepTimerEndTime - time
val newSleepEndTime = sleepTimerEndTime - time
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
// End sleep timer in 1 second
getCurrentTime() + 1000
@@ -16,6 +16,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import com.google.android.gms.cast.CastDevice
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import org.json.JSONObject
@CapacitorPlugin(name = "AbsAudioPlayer")
@@ -25,7 +27,7 @@ class AbsAudioPlayer : Plugin() {
private lateinit var mainActivity: MainActivity
private lateinit var apiHandler:ApiHandler
lateinit var castManager:CastManager
var castManager:CastManager? = null
lateinit var playerNotificationService: PlayerNotificationService
@@ -95,6 +97,24 @@ class AbsAudioPlayer : Plugin() {
}
private fun initCastManager() {
val googleApi = GoogleApiAvailability.getInstance()
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
if (statusCode != ConnectionResult.SUCCESS) {
if (statusCode == ConnectionResult.SERVICE_MISSING) {
Log.w(tag, "initCastManager: Google Api Missing")
} else if (statusCode == ConnectionResult.SERVICE_DISABLED) {
Log.w(tag, "initCastManager: Google Api Disabled")
} else if (statusCode == ConnectionResult.SERVICE_INVALID) {
Log.w(tag, "initCastManager: Google Api Invalid")
} else if (statusCode == ConnectionResult.SERVICE_UPDATING) {
Log.w(tag, "initCastManager: Google Api Updating")
} else if (statusCode == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) {
Log.w(tag, "initCastManager: Google Api Update Required")
}
return
}
val connListener = object: CastManager.ChromecastListener() {
override fun onReceiverAvailableUpdate(available: Boolean) {
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
@@ -128,7 +148,7 @@ class AbsAudioPlayer : Plugin() {
}
castManager = CastManager(mainActivity)
castManager.startRouteScan(connListener)
castManager?.startRouteScan(connListener)
}
@PluginMethod
@@ -144,7 +164,7 @@ class AbsAudioPlayer : Plugin() {
val libraryItemId = call.getString("libraryItemId", "").toString()
val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true
var playbackRate = call.getFloat("playbackRate",1f) ?: 1f
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
@@ -322,7 +342,11 @@ class AbsAudioPlayer : Plugin() {
// Need to make sure the player service has been started
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
call.resolve()
castManager.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
if (castManager == null) {
Log.e(tag, "Cast Manager not initialized")
return
}
castManager?.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
}
@@ -206,45 +206,93 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
var isFinished = call.getBoolean("isFinished", false) == true
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
val localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val isFinished = call.getBoolean("isFinished", false) == true
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.e(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId")
call.resolve(JSObject("{\"error\":\"Progress not found\"}"))
if (localMediaProgress == null) { // Create new local media progress if does not exist
Log.d(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId - Creating new")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
}
if (localLibraryItem.mediaType != "podcast" && !localEpisodeId.isNullOrEmpty()) {
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
}
var duration = 0.0
var podcastEpisode:PodcastEpisode? = null
if (!localEpisodeId.isNullOrEmpty()) {
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { episode ->
episode.id == localEpisodeId
}
if (podcastEpisode == null) {
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
}
duration = podcastEpisode.duration ?: 0.0
} else {
val book = localLibraryItem.media as Book
duration = book.duration ?: 0.0
}
val currentTime = System.currentTimeMillis()
localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = duration,
progress = if (isFinished) 1.0 else 0.0,
currentTime = 0.0,
isFinished = isFinished,
lastUpdate = currentTime,
startedAt = if (isFinished) currentTime else 0L,
finishedAt = if (isFinished) currentTime else null,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = podcastEpisode?.serverEpisodeId)
} else {
localMediaProgress.updateIsFinished(isFinished)
}
var lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
// Save local media progress locally
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
// Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject()
updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
// call.resolve(JSObject("{\"local\":true,\"server\":true,\"localMediaProgress\":$lmpstring}"))
}
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
// Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject()
updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
}
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
// call.resolve(JSObject("{\"local\":true,\"localMediaProgress\":$lmpstring}}"))
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
}
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
}
@@ -180,15 +180,30 @@ class AbsDownloader : Plugin() {
// Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String {
val cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
cleanedRelPath = cleanStringForFileSystem(cleanedRelPath)
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Replace characters that cant be used in the file system
// Reserved characters: ?:\"*|/\\<>
private fun cleanStringForFileSystem(str:String):String {
val reservedCharacters = listOf("?", "\"", "*", "|", "/", "\\", "<", ">")
var newTitle = str
newTitle = newTitle.replace(":", " -") // Special case replace : with -
reservedCharacters.forEach {
newTitle = newTitle.replace(it, "")
}
return newTitle
}
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
if (libraryItem.mediaType == "book") {
val bookTitle = libraryItem.media.metadata.title
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
@@ -243,8 +258,8 @@ class AbsDownloader : Plugin() {
}
} else {
// Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val podcastTitle = libraryItem.media.metadata.title
val audioTrack = episode?.audioTrack
Log.d(tag, "Starting podcast episode download")
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
@@ -310,6 +325,13 @@ class AbsDownloader : Plugin() {
delay(500)
}
// Remove download notifications
downloadItem.downloadItemParts.forEach { downloadItemPart ->
downloadItemPart.downloadId?.let {
downloadManager.remove(it)
}
}
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
downloadQueue.remove(downloadItem)
@@ -352,6 +374,7 @@ class AbsDownloader : Plugin() {
if (!downloadItemPart.completed) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
downloadItemPart.completed = true
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}")
+275 -40
View File
@@ -1,10 +1,10 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/fonts/MaterialIcons.woff2) format('woff2');
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
@@ -12,43 +12,6 @@
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
}
/* .material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
line-height: 1;
font-size: 1.5rem;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons.text-icon {
font-size: 1.15rem;
}
.material-icons.text-lg {
font-size: 1.25rem;
}
.material-icons.text-2xl {
font-size: 1.5rem;
}
.material-icons.text-3xl {
font-size: 1.875rem;
}
.material-icons.text-4xl {
font-size: 2.25rem;
}
.material-icons.text-5xl {
font-size: 3rem;
}
.material-icons.text-base {
font-size: 1rem;
} */
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
@@ -60,9 +23,9 @@
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem;
}
@@ -78,9 +41,9 @@
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem;
}
@@ -93,6 +56,7 @@
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
@@ -101,4 +65,275 @@
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* latin-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+1 -3
View File
@@ -80,11 +80,9 @@ export default {
methods: {
castClick() {
if (this.$store.state.playerIsLocal) {
this.$toast.warn('Cannot cast downloaded media item')
this.$eventBus.$emit('cast-local-item')
return
}
console.log('Cast Btn Click')
AbsAudioPlayer.requestSession()
},
clickShowSideDrawer() {
+8 -6
View File
@@ -4,7 +4,7 @@
<div class="top-2 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
</div>
<div v-show="showCastBtn" class="top-3.5 right-20 absolute cursor-pointer">
<div v-show="showCastBtn" class="top-4 right-16 absolute cursor-pointer">
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div>
<div class="top-4 right-4 absolute cursor-pointer">
@@ -12,6 +12,7 @@
<span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu>
</div>
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
</div>
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
@@ -63,7 +64,7 @@
<div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<div class="play-btn cursor-pointer shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
@@ -159,7 +160,7 @@ export default {
return this.showFullscreen ? 200 : 60
},
showCastBtn() {
return this.$store.state.isCastAvailable && !this.isLocalPlayMethod
return this.$store.state.isCastAvailable
},
isCasting() {
return this.mediaPlayer === 'cast-player'
@@ -193,6 +194,9 @@ export default {
isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL
},
isDirectPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
},
title() {
if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
@@ -269,12 +273,10 @@ export default {
this.showChapterModal = false
},
castClick() {
console.log('Cast Btn Click')
if (this.isLocalPlayMethod) {
this.$toast.warn('Cannot cast downloaded media items')
this.$eventBus.$emit('cast-local-item')
return
}
AbsAudioPlayer.requestSession()
},
clickContainer() {
+30
View File
@@ -168,10 +168,36 @@ export default {
this.$refs.audioPlayer.closePlayback()
}
},
castLocalItem() {
if (!this.serverLibraryItemId) {
this.$toast.error(`Cannot cast locally downloaded media`)
} else {
// Change to server library item
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
}
},
playServerLibraryItemAndCast(libraryItemId) {
var playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
})
.catch((error) => {
console.error('Failed', error)
})
},
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId
// When playing local library item and can also play this item from the server
// then store the server library item id so it can be used if a cast is made
var serverLibraryItemId = payload.serverLibraryItemId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({
title: 'Warning',
@@ -195,6 +221,8 @@ export default {
console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
} else {
this.serverLibraryItemId = serverLibraryItemId
}
})
.catch((error) => {
@@ -228,6 +256,7 @@ export default {
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
},
beforeDestroy() {
@@ -246,6 +275,7 @@ export default {
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$store.commit('user/removeSettingsListener', 'streamContainer')
}
}
+1
View File
@@ -3,6 +3,7 @@
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div v-else class="flex border-t border-white border-opacity-10 my-3 py-1"/>
</div>
</template>
-4
View File
@@ -446,10 +446,6 @@ export default {
this.selected = !this.selected
this.$emit('select', this.libraryItem)
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
+16
View File
@@ -20,6 +20,8 @@
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
<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>
</div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
@@ -99,9 +101,23 @@ export default {
mediaType() {
return this._libraryItem.mediaType
},
duration() {
return this.media.duration || null
},
isPodcast() {
return this.mediaType === 'podcast'
},
episodes() {
if (this.isPodcast) {
if (this.media.numEpisodes==1) {
return "1 episode"
} else {
return this.media.numEpisodes + ' episodes'
}
} else {
return null
}
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
+9 -6
View File
@@ -27,9 +27,11 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons px-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
<div v-if="!isIos">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div>
</div>
</div>
@@ -61,6 +63,9 @@ export default {
}
},
computed: {
isIos() {
return this.$platform === 'ios'
},
mediaType() {
return 'podcast'
},
@@ -204,9 +209,7 @@ export default {
var isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var localMediaProgressId = `${localLibraryItemId}-${localEpisodeId}`
console.log('toggleFinished local media progress id', localMediaProgressId, isFinished)
var payload = await this.$db.updateLocalMediaProgressFinished({ localMediaProgressId, isFinished })
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) {
var errorMsg = payload ? payload.error : 'Unknown error'
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<button class="icon-btn rounded-md flex items-center justify-center px-2 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
+6 -6
View File
@@ -459,14 +459,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.43;
MARKETING_VERSION = 0.9.44;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.development;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OBJC_BRIDGING_HEADER = "App/App-Bridging-Header.h";
@@ -483,12 +483,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.43;
MARKETING_VERSION = 0.9.44;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+7 -1
View File
@@ -14,13 +14,19 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seek, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seekForward, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seekBackward, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getCurrentTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(cancelSleepTimer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(decreaseSleepTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(increaseSleepTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getSleepTimerTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setSleepTimer, CAPPluginReturnPromise);
)
+104 -38
View File
@@ -10,12 +10,19 @@ import Capacitor
@objc(AbsAudioPlayer)
public class AbsAudioPlayer: CAPPlugin {
private var initialPlayWhenReady = false
private var initialPlaybackRate:Float = 1
override public func load() {
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendPlaybackClosedEvent), name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
}
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
@@ -33,8 +40,11 @@ public class AbsAudioPlayer: CAPPlugin {
return call.resolve()
}
initialPlayWhenReady = playWhenReady
initialPlaybackRate = playbackRate
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId) { session in
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
do {
@@ -43,7 +53,6 @@ public class AbsAudioPlayer: CAPPlugin {
} catch(let exception) {
NSLog("failed to convert session to json")
debugPrint(exception)
call.resolve([:])
}
@@ -68,18 +77,19 @@ public class AbsAudioPlayer: CAPPlugin {
call.resolve()
}
@objc func playPause(_ call: CAPPluginCall) {
PlayerHandler.playPause()
call.resolve([ "playing": !PlayerHandler.paused() ])
}
@objc func playPlayer(_ call: CAPPluginCall) {
PlayerHandler.play()
PlayerHandler.paused = false
call.resolve()
}
@objc func pausePlayer(_ call: CAPPluginCall) {
PlayerHandler.pause()
PlayerHandler.paused = true
call.resolve()
}
// I have no clue why but after i moved this block of code from above "playPlayer" to here the app stopped crashing. Move it back up if you want to
@objc func playPause(_ call: CAPPluginCall) {
PlayerHandler.paused = !PlayerHandler.paused
call.resolve([ "playing": !PlayerHandler.paused ])
}
@objc func seek(_ call: CAPPluginCall) {
PlayerHandler.seek(amount: call.getDouble("value", 0.0))
@@ -95,13 +105,97 @@ public class AbsAudioPlayer: CAPPlugin {
}
@objc func sendMetadata() {
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused() ])
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
}
@objc func sendPlaybackClosedEvent() {
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
}
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000)
call.resolve()
}
@objc func increaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000)
call.resolve()
}
@objc func setSleepTimer(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
NSLog("chapter time: \(call.getBool("isChapterTime", false))")
if call.getBool("isChapterTime", false) {
let timeToPause = time / 1000 - Int(PlayerHandler.getCurrentTime() ?? 0)
if timeToPause < 0 { return call.resolve([ "success": false ]) }
NSLog("oof \(timeToPause)")
PlayerHandler.remainingSleepTime = timeToPause
return call.resolve([ "success": true ])
}
PlayerHandler.remainingSleepTime = time / 1000
call.resolve([ "success": true ])
}
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
PlayerHandler.remainingSleepTime = nil
call.resolve()
}
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
call.resolve([
"value": PlayerHandler.remainingSleepTime
])
}
@objc func sendSleepTimerEnded() {
self.notifyListeners("onSleepTimerEnded", data: [
"value": PlayerHandler.getCurrentTime()
])
}
@objc func sendSleepTimerSet() {
self.notifyListeners("onSleepTimerSet", data: [
"value": PlayerHandler.remainingSleepTime
])
}
@objc func onPlaybackFailed() {
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
let playbackSession = PlayerHandler.getPlaybackSession()
let libraryItemId = playbackSession?.libraryItemId ?? ""
let episodeId = playbackSession?.episodeId ?? nil
NSLog("TEST: Forcing Transcode")
// If direct playing then fallback to transcode
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
do {
self.sendPlaybackSession(session: try session.asDictionary())
} catch(let exception) {
NSLog("failed to convert session to json")
debugPrint(exception)
}
self.sendMetadata()
}
} else {
self.notifyListeners("onPlaybackFailed", data: [
"value": "Playback Error"
])
}
}
@objc func sendPrepareMetadataEvent(itemId: String, playWhenReady: Bool) {
self.notifyListeners("onPrepareMedia", data: [
"audiobookId": itemId,
@@ -111,32 +205,4 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func sendPlaybackSession(session: [String: Any]) {
self.notifyListeners("onPlaybackSession", data: session)
}
/*
IMPLEMENTED:
cancelSleepTimer
decreaseSleepTime
increaseSleepTime
getSleepTimerTime
setSleepTimer
* closePlayback
* setPlaybackSpeed
* seekBackward
* seekForward
* seek
* playPause
* playPlayer
* pausePlayer
* getCurrentTime
* onPlaybackSession
* onPrepareMedia
* onPlaybackClosed
* onPlayingUpdate
* onMetadata
onSleepTimerEnded
onSleepTimerSet
*/
}
+4 -5
View File
@@ -53,7 +53,7 @@ public class AbsDatabase: CAPPlugin {
}
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
let id = call.getString("serverConnectionConfigId", "")
Database.deleteServerConnectionConfig(id: id)
Database.shared.deleteServerConnectionConfig(id: id)
call.resolve()
}
@@ -63,13 +63,12 @@ public class AbsDatabase: CAPPlugin {
}
@objc func getDeviceData(_ call: CAPPluginCall) {
let configs = Database.getServerConnectionConfigs()
let index = Database.getLastActiveConfigIndex()
let configs = Database.shared.getServerConnectionConfigs()
let index = Database.shared.getLastActiveConfigIndex()
call.resolve([
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id,
// Luckily this isn't implemented yet
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
// "currentLocalPlaybackSession": nil,
])
}
+167 -64
View File
@@ -10,6 +10,13 @@ import AVFoundation
import UIKit
import MediaPlayer
enum PlayMethod:Int {
case directplay = 0
case directstream = 1
case transcode = 2
case local = 3
}
class AudioPlayer: NSObject {
// enums and @objc are not compatible
@objc dynamic var status: Int
@@ -24,29 +31,25 @@ class AudioPlayer: NSObject {
private var playWhenReady: Bool
private var initialPlaybackRate: Float
private var audioPlayer: AVPlayer
private var audioPlayer: AVQueuePlayer
private var playbackSession: PlaybackSession
private var activeAudioTrack: AudioTrack
private var queueObserver:NSKeyValueObservation?
private var queueItemStatusObserver:NSKeyValueObservation?
private var currentTrackIndex = 0
private var allPlayerItems:[AVPlayerItem] = []
// MARK: - Constructor
init(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) {
self.playWhenReady = playWhenReady
self.initialPlaybackRate = playbackRate
self.audioPlayer = AVPlayer()
self.audioPlayer = AVQueuePlayer()
self.playbackSession = playbackSession
self.status = -1
self.rate = 0.0
self.tmpRate = playbackRate
if playbackSession.audioTracks.count != 1 || playbackSession.audioTracks[0].mimeType != "application/vnd.apple.mpegurl" {
NSLog("The player only support HLS streams right now")
self.activeAudioTrack = AudioTrack(index: 0, startOffset: -1, duration: -1, title: "", contentUrl: nil, mimeType: "", metadata: nil, serverIndex: 0)
super.init()
return
}
self.activeAudioTrack = playbackSession.audioTracks[0]
super.init()
initAudioSession()
@@ -56,19 +59,35 @@ class AudioPlayer: NSObject {
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
let playerItem = AVPlayerItem(asset: createAsset())
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: .new, context: &playerItemContext)
for track in playbackSession.audioTracks {
let playerItem = AVPlayerItem(asset: createAsset(itemId: playbackSession.libraryItemId!, track: track))
self.allPlayerItems.append(playerItem)
}
self.audioPlayer.replaceCurrentItem(with: playerItem)
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
NSLog("TEST: Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
NSLog("TEST: Setting player items \(playerItems.count)")
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
setupQueueObserver()
setupQueueItemStatusObserver()
NSLog("Audioplayer ready")
}
deinit {
self.queueObserver?.invalidate()
self.queueItemStatusObserver?.invalidate()
destroy()
}
public func destroy() {
// Pause is not synchronous causing this error on below lines:
// AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session
// It is related to L79 `AVAudioSession.sharedInstance().setActive(false)`
pause()
audioPlayer.replaceCurrentItem(with: nil)
@@ -79,14 +98,60 @@ class AudioPlayer: NSObject {
print(error)
}
// Throws error Possibly related to the error above
// DispatchQueue.main.sync {
// UIApplication.shared.endReceivingRemoteControlEvents()
// }
DispatchQueue.runOnMainQueue {
UIApplication.shared.endReceivingRemoteControlEvents()
}
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
}
func getItemIndexForTime(time:Double) -> Int {
for index in 0..<self.allPlayerItems.count {
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
let duration = playbackSession.audioTracks[index].duration
let trackEnd = startOffset + duration
if (time < trackEnd.rounded(.down)) {
return index
}
}
return 0
}
func setupQueueObserver() {
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in
let prevTrackIndex = self.currentTrackIndex
self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) {
NSLog("TEST: New Current track index \(self.currentTrackIndex)")
}
}
}
}
func setupQueueItemStatusObserver() {
self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
if (playerItem.status == .readyToPlay) {
NSLog("TEST: queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
self.updateNowPlaying()
let firstReady = self.status < 0
self.status = 0
if self.playWhenReady {
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
self.playWhenReady = false
self.play()
} else if (firstReady) { // Only seek on first readyToPlay
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
}
} else if (playerItem.status == .failed) {
NSLog("TEST: queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
})
}
// MARK: - Methods
public func play(allowSeekBack: Bool = false) {
if allowSeekBack {
@@ -110,11 +175,11 @@ class AudioPlayer: NSObject {
}
if time != nil {
seek(getCurrentTime() - Double(time!))
seek(getCurrentTime() - Double(time!), from: "play")
}
}
lastPlayTime = Date.timeIntervalSinceReferenceDate
self.audioPlayer.play()
self.status = 1
self.rate = self.tmpRate
@@ -122,6 +187,7 @@ class AudioPlayer: NSObject {
updateNowPlaying()
}
public func pause() {
self.audioPlayer.pause()
self.status = 0
@@ -130,24 +196,60 @@ class AudioPlayer: NSObject {
updateNowPlaying()
lastPlayTime = Date.timeIntervalSinceReferenceDate
}
public func seek(_ to: Double) {
let continuePlaing = rate > 0.0
public func seek(_ to: Double, from: String) {
let continuePlaying = rate > 0.0
pause()
self.audioPlayer.seek(to: CMTime(seconds: to, preferredTimescale: 1000)) { completed in
if !completed {
NSLog("WARNING: seeking not completed (to \(to)")
NSLog("TEST: Seek to \(to) from \(from)")
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration
NSLog("TEST: Seek current track END = \(trackEnd)")
let indexOfSeek = getItemIndexForTime(time: to)
NSLog("TEST: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
// Reconstruct queue if seeking to a different track
if (self.currentTrackIndex != indexOfSeek) {
self.currentTrackIndex = indexOfSeek
self.playbackSession.currentTime = to
self.playWhenReady = continuePlaying // Only playWhenReady if already playing
self.status = -1
let playerItems = self.allPlayerItems[indexOfSeek..<self.allPlayerItems.count]
self.audioPlayer.removeAllItems()
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
if continuePlaing {
self.play()
setupQueueItemStatusObserver()
} else {
NSLog("TEST: Seeking in current item \(to)")
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = to - currentTrackStartOffset
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in
if !completed {
NSLog("WARNING: seeking not completed (to \(seekTime)")
}
if continuePlaying {
self.play()
}
self.updateNowPlaying()
}
self.updateNowPlaying()
}
}
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
if self.audioPlayer.rate != rate {
NSLog("TEST: setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
self.audioPlayer.rate = rate
}
if rate > 0.0 && !(observed && rate == 1) {
@@ -155,25 +257,42 @@ class AudioPlayer: NSObject {
}
self.rate = rate
self.updateNowPlaying()
}
public func getCurrentTime() -> Double {
self.audioPlayer.currentTime().seconds
let currentTrackTime = self.audioPlayer.currentTime().seconds
let audioTrack = playbackSession.audioTracks[currentTrackIndex]
let startOffset = audioTrack.startOffset ?? 0.0
return startOffset + currentTrackTime
}
public func getPlayMethod() -> Int {
return self.playbackSession.playMethod
}
public func getPlaybackSession() -> PlaybackSession {
return self.playbackSession
}
public func getDuration() -> Double {
self.audioPlayer.currentItem?.duration.seconds ?? 0
return playbackSession.duration
}
// MARK: - Private
private func createAsset() -> AVAsset {
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(activeAudioTrack.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset {
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
// The only reason this is separate is because the filename needs to be encoded
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
} else { // HLS Transcode
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
}
}
private func initAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay])
@@ -186,9 +305,9 @@ class AudioPlayer: NSObject {
// MARK: - Now playing
private func setupRemoteTransportControls() {
// DispatchQueue.main.sync {
DispatchQueue.runOnMainQueue {
UIApplication.shared.beginReceivingRemoteControlEvents()
// }
}
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
@@ -209,7 +328,7 @@ class AudioPlayer: NSObject {
return .noSuchContent
}
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue)
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue, from: "remote")
return .success
}
commandCenter.skipBackwardCommand.isEnabled = true
@@ -219,7 +338,7 @@ class AudioPlayer: NSObject {
return .noSuchContent
}
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue)
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue, from: "remote")
return .success
}
@@ -229,7 +348,7 @@ class AudioPlayer: NSObject {
return .noSuchContent
}
self.seek(event.positionTime)
self.seek(event.positionTime, from: "remote")
return .success
}
@@ -246,33 +365,17 @@ class AudioPlayer: NSObject {
}
private func updateNowPlaying() {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NowPlayingInfo.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate)
NowPlayingInfo.shared.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate)
}
// MARK: - Observer
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &playerItemContext {
if keyPath == #keyPath(AVPlayer.status) {
guard let playerStatus = AVPlayerItem.Status(rawValue: (change?[.newKey] as? Int ?? -1)) else { return }
if playerStatus == .readyToPlay {
self.updateNowPlaying()
let firstReady = self.status < 0
self.status = 0
if self.playWhenReady {
seek(playbackSession.currentTime)
self.playWhenReady = false
self.play()
} else if (firstReady) { // Only seek on first readyToPlay
seek(playbackSession.currentTime)
}
}
}
} else if context == &playerContext {
if context == &playerContext {
if keyPath == #keyPath(AVPlayer.rate) {
NSLog("TEST: playerContext observer player rate")
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
} else if keyPath == #keyPath(AVPlayer.currentItem) {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NSLog("WARNING: Item ended")
}
} else {
+73 -36
View File
@@ -12,7 +12,44 @@ class PlayerHandler {
private static var session: PlaybackSession?
private static var timer: Timer?
private static var listeningTimePassedSinceLastSync = 0.0
private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? {
get {
return _remainingSleepTime
}
set(time) {
if time != nil && time! < 0 {
_remainingSleepTime = nil
} else {
_remainingSleepTime = time
}
if _remainingSleepTime == nil {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: _remainingSleepTime)
} else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: _remainingSleepTime)
}
}
}
private static var listeningTimePassedSinceLastSync: Double = 0.0
private static var lastSyncReport: PlaybackReport?
public static var paused: Bool {
get {
guard let player = player else {
return true
}
return player.rate == 0.0
}
set(paused) {
if paused {
self.player?.pause()
} else {
self.player?.play()
}
}
}
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
if player != nil {
@@ -20,16 +57,16 @@ class PlayerHandler {
player = nil
}
NowPlayingInfo.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
self.session = session
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
// DispatchQueue.main.sync {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
DispatchQueue.runOnMainQueue {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
// }
}
public static func stopPlayback() {
player?.destroy()
@@ -38,7 +75,7 @@ class PlayerHandler {
timer?.invalidate()
timer = nil
NowPlayingInfo.reset()
NowPlayingInfo.shared.reset()
}
public static func getCurrentTime() -> Double? {
@@ -47,45 +84,32 @@ class PlayerHandler {
public static func setPlaybackSpeed(speed: Float) {
self.player?.setPlaybackRate(speed)
}
public static func play() {
self.player?.play()
public static func getPlayMethod() -> Int? {
self.player?.getPlayMethod()
}
public static func pause() {
self.player?.play()
}
public static func playPause() {
if paused() {
self.player?.play()
} else {
self.player?.pause()
}
public static func getPlaybackSession() -> PlaybackSession? {
self.player?.getPlaybackSession()
}
public static func seekForward(amount: Double) {
if player == nil {
guard let player = player else {
return
}
let destinationTime = player!.getCurrentTime() + amount
player!.seek(destinationTime)
let destinationTime = player.getCurrentTime() + amount
player.seek(destinationTime, from: "handler")
}
public static func seekBackward(amount: Double) {
if player == nil {
guard let player = player else {
return
}
let destinationTime = player!.getCurrentTime() - amount
player!.seek(destinationTime)
let destinationTime = player.getCurrentTime() - amount
player.seek(destinationTime, from: "handler")
}
public static func seek(amount: Double) {
player?.seek(amount)
player?.seek(amount, from: "handler")
}
public static func paused() -> Bool {
player?.rate == 0.0
}
public static func getMetdata() -> [String: Any] {
DispatchQueue.main.async {
syncProgress()
@@ -94,29 +118,42 @@ class PlayerHandler {
return [
"duration": player?.getDuration() ?? 0,
"currentTime": player?.getCurrentTime() ?? 0,
"playerState": !paused(),
"playerState": !paused,
"currentRate": player?.rate ?? 0,
]
}
private static func tick() {
if !paused() {
if !paused {
listeningTimePassedSinceLastSync += 1
}
if listeningTimePassedSinceLastSync > 3 {
syncProgress()
}
if remainingSleepTime != nil {
if remainingSleepTime! == 0 {
paused = true
}
remainingSleepTime! -= 1
}
}
public static func syncProgress() {
if player == nil || session == nil {
if session == nil { return }
guard let player = player else { return }
let playerCurrentTime = player.getCurrentTime()
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
// No need to syncProgress
return
}
let report = PlaybackReport(currentTime: player!.getCurrentTime(), duration: player!.getDuration(), timeListened: listeningTimePassedSinceLastSync)
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = player!.getCurrentTime()
session!.currentTime = playerCurrentTime
listeningTimePassedSinceLastSync = 0
lastSyncReport = report
// TODO: check if online
NSLog("sending playback report")
+11 -3
View File
@@ -9,6 +9,14 @@ import Foundation
import Alamofire
class ApiClient {
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
if let data = data {
completion(UIImage(data:data))
}
}).resume()
}
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: String], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
@@ -53,15 +61,15 @@ class ApiClient {
}
}
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: PlaybackSession) -> Void) {
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
var endpoint = "api/items/\(libraryItemId)/play"
if episodeId != nil {
endpoint += "/\(episodeId!)"
}
ApiClient.postResource(endpoint: endpoint, parameters: [
"forceTranscode": "true", // TODO: direct play
"forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer",
], decodable: PlaybackSession.self) { obj in
var session = obj
+22 -13
View File
@@ -10,16 +10,25 @@ import RealmSwift
class Database {
// All DB releated actions must be executed on "realm-queue"
public static let realmQueue = DispatchQueue(label: "realm-queue")
private static var instance: Realm = try! Realm(queue: realmQueue)
public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue")
public static var shared = {
realmQueue.sync {
return Database()
}
}()
private var instance: Realm
private init() {
self.instance = try! Realm(queue: Database.realmQueue)
}
public static func setServerConnectionConfig(config: ServerConnectionConfig) {
public func setServerConnectionConfig(config: ServerConnectionConfig) {
var refrence: ThreadSafeReference<ServerConnectionConfig>?
if config.realm != nil {
refrence = ThreadSafeReference(to: config)
}
realmQueue.sync {
Database.realmQueue.sync {
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
if config.index == 0 {
@@ -55,8 +64,8 @@ class Database {
setLastActiveConfigIndex(index: config.index)
}
}
public static func deleteServerConnectionConfig(id: String) {
realmQueue.sync {
public func deleteServerConnectionConfig(id: String) {
Database.realmQueue.sync {
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
do {
@@ -71,10 +80,10 @@ class Database {
}
}
}
public static func getServerConnectionConfigs() -> [ServerConnectionConfig] {
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
realmQueue.sync {
Database.realmQueue.sync {
let configs = instance.objects(ServerConnectionConfig.self)
refrences = configs.map { config in
return ThreadSafeReference(to: config)
@@ -94,12 +103,12 @@ class Database {
}
}
public static func setLastActiveConfigIndexToNil() {
realmQueue.sync {
public func setLastActiveConfigIndexToNil() {
Database.realmQueue.sync {
setLastActiveConfigIndex(index: nil)
}
}
public static func setLastActiveConfigIndex(index: Int?) {
public func setLastActiveConfigIndex(index: Int?) {
let existing = instance.objects(ServerConnectionConfigActiveIndex.self)
let obj = ServerConnectionConfigActiveIndex()
obj.index = index
@@ -114,8 +123,8 @@ class Database {
debugPrint(exception)
}
}
public static func getLastActiveConfigIndex() -> Int? {
return realmQueue.sync {
public func getLastActiveConfigIndex() -> Int? {
return Database.realmQueue.sync {
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
}
}
+11
View File
@@ -18,3 +18,14 @@ extension Encodable {
return dictionary
}
}
extension DispatchQueue {
static func runOnMainQueue(callback: @escaping (() -> Void)) {
if Thread.isMainThread {
callback()
} else {
DispatchQueue.main.sync {
callback()
}
}
}
}
+14 -22
View File
@@ -8,14 +8,6 @@
import Foundation
import MediaPlayer
func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
if let data = data {
completion(UIImage(data:data))
}
}).resume()
}
struct NowPlayingMetadata {
var id: String
var itemId: String
@@ -26,22 +18,22 @@ struct NowPlayingMetadata {
}
class NowPlayingInfo {
private static var nowPlayingInfo: [String: Any] = [:]
static var shared = {
return NowPlayingInfo()
}()
public static func setSessionMetadata(metadata: NowPlayingMetadata) {
private var nowPlayingInfo: [String: Any]
private init() {
self.nowPlayingInfo = [:]
}
public func setSessionMetadata(metadata: NowPlayingMetadata) {
setMetadata(artwork: nil, metadata: metadata)
/*
if !shouldFetchCover(id: metadata.id) || metadata.artworkUrl == nil {
return
}
*/
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
return
}
getData(from: url) { [self] image in
ApiClient.getData(from: url) { [self] image in
guard let downloadedImage = image else {
return
}
@@ -52,7 +44,7 @@ class NowPlayingInfo {
self.setMetadata(artwork: artwork, metadata: metadata)
}
}
public static func update(duration: Double, currentTime: Double, rate: Float) {
public func update(duration: Double, currentTime: Double, rate: Float) {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
@@ -60,12 +52,12 @@ class NowPlayingInfo {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
public static func reset() {
public func reset() {
nowPlayingInfo = [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
private static func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) {
private func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) {
if metadata == nil {
return
}
@@ -84,7 +76,7 @@ class NowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
}
private static func shouldFetchCover(id: String) -> Bool {
private func shouldFetchCover(id: String) -> Bool {
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
}
}
+3
View File
@@ -10,4 +10,7 @@ import Foundation
enum PlayerEvents: String {
case update = "com.audiobookshelf.app.player.update"
case closed = "com.audiobookshelf.app.player.closed"
case sleepSet = "com.audiobookshelf.app.player.sleep.set"
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
case failed = "com.audiobookshelf.app.player.failed"
}
+2 -2
View File
@@ -16,9 +16,9 @@ class Store {
}
set(updated) {
if updated != nil {
Database.setServerConnectionConfig(config: updated!)
Database.shared.setServerConnectionConfig(config: updated!)
} else {
Database.setLastActiveConfigIndexToNil()
Database.shared.setLastActiveConfigIndexToNil()
}
Database.realmQueue.sync {
+1 -2
View File
@@ -30,8 +30,7 @@ export default {
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "0.9.43-beta",
"version": "0.9.44-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",
+81 -12
View File
@@ -6,10 +6,13 @@
<covers-book-cover :library-item="libraryItem" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div>
<!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p>
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
</div>
<div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1>
<!-- <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> -->
<h3 v-if="seriesName" class="text-gray-300 text-sm leading-6">{{ seriesName }}</h3>
<p class="text-sm text-gray-400">by {{ author }}</p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1">
{{ $elapsedPretty(duration) }}
@@ -20,33 +23,36 @@
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div v-if="isLocal" class="flex mt-4 -mr-2">
<div v-if="isLocal" class="flex mt-4">
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play Local' }}</span>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play' }}</span>
</ui-btn>
<ui-btn v-if="showRead && isConnected" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4 -mr-2">
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play Local' : 'Play Stream' }}</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play' : 'Stream' }}</span>
</ui-btn>
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-btn v-if="user && showPlay && !isIos && !hasLocal" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<ui-btn v-if="user && showPlay && !isIos && !hasLocal" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mr-2" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
</div>
</div>
@@ -103,6 +109,7 @@ export default {
data() {
return {
resettingProgress: false,
isProcessingReadUpdate: false,
showSelectLocalFolder: false
}
},
@@ -129,8 +136,14 @@ export default {
var podcastMedia = this.localLibraryItem.media
return podcastMedia ? podcastMedia.episodes || [] : []
},
isConnected() {
return this.$store.state.socketConnected
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
@@ -163,6 +176,10 @@ export default {
series() {
return this.mediaMetadata.series || []
},
seriesName() {
// For books only on toJSONExpanded
return this.mediaMetadata.seriesName || ''
},
duration() {
return this.media.duration
},
@@ -233,6 +250,9 @@ export default {
},
episodes() {
return this.media.episodes || []
},
isCasting() {
return this.$store.state.isCasting
}
},
methods: {
@@ -241,7 +261,12 @@ export default {
},
playClick() {
// Todo: Allow playing local or streaming
if (this.hasLocal) return this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id })
if (this.hasLocal && this.serverLibraryItemId && this.isCasting) {
// If casting and connected to server for local library item then send server library item id
this.$eventBus.$emit('play-item', { libraryItemId: this.serverLibraryItemId })
return
}
if (this.hasLocal) return this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id, serverLibraryItemId: this.serverLibraryItemId })
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
async clearProgressClick() {
@@ -352,15 +377,59 @@ export default {
console.log('New local library item', item.id)
this.$set(this.libraryItem, 'localLibraryItem', item)
}
},
async toggleFinished() {
this.isProcessingReadUpdate = true
if (this.isLocal) {
var isFinished = !this.userIsFinished
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) {
var errorMsg = payload ? payload.error : 'Unknown error'
this.$toast.error(errorMsg)
} else {
var localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
console.log('toggleFinished Check LMP', this.libraryItemId, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
}
this.isProcessingReadUpdate = false
} else {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
}
}
},
mounted() {
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.on('item_updated', this.itemUpdated)
this.$socket.on('item_updated', this.itemUpdated)
},
beforeDestroy() {
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.off('item_updated', this.itemUpdated)
this.$socket.off('item_updated', this.itemUpdated)
}
}
</script>
+2 -1
View File
@@ -199,7 +199,8 @@ class AbsDatabaseWeb extends WebPlugin {
return []
}
async updateLocalMediaProgressFinished({ localMediaProgressId, isFinished }) {
async updateLocalMediaProgressFinished(payload) {
// { localLibraryItemId, localEpisodeId, isFinished }
return null
}
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name Source.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.