mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-09 21:38:36 +02:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c49bcfe3f | ||
|
|
aecb6a8dd2 | ||
|
|
718947522e | ||
|
|
2f8ca51447 | ||
|
|
4fa6dd2616 | ||
|
|
736e57fafd | ||
|
|
30d86279a5 | ||
|
|
8bbfcdeb82 | ||
|
|
d23cf62264 | ||
|
|
73d5b19d2b | ||
|
|
1959351125 | ||
|
|
0708133779 | ||
|
|
9a81fc3688 | ||
|
|
ac71d39265 | ||
|
|
4203654ec8 | ||
|
|
9701c767b2 | ||
|
|
0223df4f9e | ||
|
|
7549385404 | ||
|
|
3f2d0ed8b1 | ||
|
|
e4a5927e07 | ||
|
|
a3aac4da75 | ||
|
|
1e9453e501 | ||
|
|
bec3f5841e | ||
|
|
068762912f | ||
|
|
01e85d0e91 | ||
|
|
a67c19f30f | ||
|
|
394363c8cb | ||
|
|
7fd51ebcc1 | ||
|
|
ae4678cf24 | ||
|
|
68e565ebe2 | ||
|
|
d99f4406b7 | ||
|
|
2064cd8380 | ||
|
|
415ff65561 | ||
|
|
1fed00ca81 | ||
|
|
a63022a669 | ||
|
|
114dbd24bc |
@@ -29,8 +29,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 72
|
versionCode 73
|
||||||
versionName "0.9.43-beta"
|
versionName "0.9.44-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+21
-3
@@ -5,6 +5,9 @@ import android.graphics.Bitmap
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.util.Log
|
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.audiobookshelf.app.R
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
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? {
|
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
// Block on downloading artwork.
|
// 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 {
|
try {
|
||||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
Glide.with(context).applyDefaultRequestOptions(glideOptions)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(uri)
|
.load(urival)
|
||||||
.placeholder(R.drawable.icon)
|
.placeholder(R.drawable.icon)
|
||||||
.error(R.drawable.icon)
|
.error(R.drawable.icon)
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||||
@@ -71,7 +89,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|
||||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
Glide.with(context).applyDefaultRequestOptions(glideOptions)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.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"
|
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getContext():Context {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.audiobookshelf.app.player
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
import android.os.Handler
|
import android.content.Context
|
||||||
import android.os.Looper
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
@@ -9,9 +9,8 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
||||||
|
|
||||||
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) {
|
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
|
||||||
private val tag = "SleepTimerManager"
|
private val tag = "SleepTimerManager"
|
||||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
|
||||||
|
|
||||||
private var sleepTimerTask:TimerTask? = null
|
private var sleepTimerTask:TimerTask? = null
|
||||||
private var sleepTimerRunning:Boolean = false
|
private var sleepTimerRunning:Boolean = false
|
||||||
@@ -64,7 +63,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
// Register shake sensor
|
// Register shake sensor
|
||||||
playerNotificationService.registerSensor()
|
playerNotificationService.registerSensor()
|
||||||
|
|
||||||
var currentTime = getCurrentTime()
|
val currentTime = getCurrentTime()
|
||||||
if (isChapterTime) {
|
if (isChapterTime) {
|
||||||
if (currentTime > time) {
|
if (currentTime > time) {
|
||||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $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()) {
|
if (getIsPlaying()) {
|
||||||
sleepTimerElapsed += 1000L
|
sleepTimerElapsed += 1000L
|
||||||
|
|
||||||
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||||
|
|
||||||
if (sleepTimeSecondsRemaining > 0) {
|
if (sleepTimeSecondsRemaining > 0) {
|
||||||
@@ -111,7 +110,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||||
} else if (sleepTimeSecondsRemaining <= 30) {
|
} else if (sleepTimeSecondsRemaining <= 30) {
|
||||||
// Start fading out audio
|
// Start fading out audio
|
||||||
var volume = sleepTimeSecondsRemaining / 30F
|
val volume = sleepTimeSecondsRemaining / 30F
|
||||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||||
setVolume(volume)
|
setVolume(volume)
|
||||||
}
|
}
|
||||||
@@ -129,7 +128,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
playerNotificationService.unregisterSensor()
|
playerNotificationService.unregisterSensor()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSleepTimerTime():Long? {
|
fun getSleepTimerTime():Long {
|
||||||
return sleepTimerEndTime
|
return sleepTimerEndTime
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +138,30 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
|
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() {
|
private fun extendSleepTime() {
|
||||||
if (!sleepTimerRunning) return
|
if (!sleepTimerRunning) return
|
||||||
setVolume(1F)
|
setVolume(1F)
|
||||||
@@ -157,7 +180,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
if (!sleepTimerRunning) {
|
if (!sleepTimerRunning) {
|
||||||
if (sleepTimerFinishedAt <= 0L) return
|
if (sleepTimerFinishedAt <= 0L) return
|
||||||
|
|
||||||
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||||
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
|
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
|
||||||
{
|
{
|
||||||
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
|
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
|
||||||
@@ -165,14 +188,18 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
return
|
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)
|
setSleepTimer(newSleepTime, false)
|
||||||
play()
|
play()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only extend if within 30 seconds of finishing
|
// Only extend if within 30 seconds of finishing
|
||||||
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
||||||
if (sleepTimeRemaining <= 30) extendSleepTime()
|
if (sleepTimeRemaining <= 30) {
|
||||||
|
vibrate()
|
||||||
|
extendSleepTime()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleShake() {
|
fun handleShake() {
|
||||||
@@ -188,7 +215,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
sleepTimerLength += time
|
sleepTimerLength += time
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||||
} else {
|
} else {
|
||||||
var newSleepEndTime = sleepTimerEndTime + time
|
val newSleepEndTime = sleepTimerEndTime + time
|
||||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||||
getDuration()
|
getDuration()
|
||||||
} else {
|
} else {
|
||||||
@@ -209,7 +236,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
|
|||||||
sleepTimerLength -= time
|
sleepTimerLength -= time
|
||||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
||||||
} else {
|
} else {
|
||||||
var newSleepEndTime = sleepTimerEndTime - time
|
val newSleepEndTime = sleepTimerEndTime - time
|
||||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||||
// End sleep timer in 1 second
|
// End sleep timer in 1 second
|
||||||
getCurrentTime() + 1000
|
getCurrentTime() + 1000
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|||||||
import com.getcapacitor.*
|
import com.getcapacitor.*
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
import com.google.android.gms.cast.CastDevice
|
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
|
import org.json.JSONObject
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsAudioPlayer")
|
@CapacitorPlugin(name = "AbsAudioPlayer")
|
||||||
@@ -25,7 +27,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
private lateinit var mainActivity: MainActivity
|
private lateinit var mainActivity: MainActivity
|
||||||
private lateinit var apiHandler:ApiHandler
|
private lateinit var apiHandler:ApiHandler
|
||||||
lateinit var castManager:CastManager
|
var castManager:CastManager? = null
|
||||||
|
|
||||||
lateinit var playerNotificationService: PlayerNotificationService
|
lateinit var playerNotificationService: PlayerNotificationService
|
||||||
|
|
||||||
@@ -95,6 +97,24 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initCastManager() {
|
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() {
|
val connListener = object: CastManager.ChromecastListener() {
|
||||||
override fun onReceiverAvailableUpdate(available: Boolean) {
|
override fun onReceiverAvailableUpdate(available: Boolean) {
|
||||||
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
|
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
|
||||||
@@ -128,7 +148,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
castManager = CastManager(mainActivity)
|
castManager = CastManager(mainActivity)
|
||||||
castManager.startRouteScan(connListener)
|
castManager?.startRouteScan(connListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
@@ -144,7 +164,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||||
val episodeId = call.getString("episodeId", "").toString()
|
val episodeId = call.getString("episodeId", "").toString()
|
||||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||||
var playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||||
|
|
||||||
if (libraryItemId.isEmpty()) {
|
if (libraryItemId.isEmpty()) {
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
Log.e(tag, "Invalid call to play library item no library item id")
|
||||||
@@ -322,7 +342,11 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
// Need to make sure the player service has been started
|
// Need to make sure the player service has been started
|
||||||
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
||||||
call.resolve()
|
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) {
|
override fun onError(errorCode: Int) {
|
||||||
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
|
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,45 +206,93 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun updateLocalMediaProgressFinished(call:PluginCall) {
|
fun updateLocalMediaProgressFinished(call:PluginCall) {
|
||||||
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||||
var isFinished = call.getBoolean("isFinished", false) == true
|
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")
|
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
|
||||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||||
if (localMediaProgress == null) {
|
|
||||||
Log.e(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId")
|
if (localMediaProgress == null) { // Create new local media progress if does not exist
|
||||||
call.resolve(JSObject("{\"error\":\"Progress not found\"}"))
|
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 {
|
} else {
|
||||||
localMediaProgress.updateIsFinished(isFinished)
|
localMediaProgress.updateIsFinished(isFinished)
|
||||||
|
}
|
||||||
|
|
||||||
var lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
// Save local media progress locally
|
||||||
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
|
|
||||||
// Send update to server media progress is linked to a server and user is logged into that server
|
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
||||||
localMediaProgress.serverConnectionConfigId?.let { configId ->
|
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
|
||||||
if (DeviceManager.serverConnectionConfigId == configId) {
|
|
||||||
var libraryItemId = localMediaProgress.libraryItemId ?: ""
|
// Send update to server media progress is linked to a server and user is logged into that server
|
||||||
var episodeId = localMediaProgress.episodeId ?: ""
|
localMediaProgress.serverConnectionConfigId?.let { configId ->
|
||||||
var updatePayload = JSObject()
|
if (DeviceManager.serverConnectionConfigId == configId) {
|
||||||
updatePayload.put("isFinished", isFinished)
|
var libraryItemId = localMediaProgress.libraryItemId ?: ""
|
||||||
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
var episodeId = localMediaProgress.episodeId ?: ""
|
||||||
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
var updatePayload = JSObject()
|
||||||
var jsobj = JSObject()
|
updatePayload.put("isFinished", isFinished)
|
||||||
jsobj.put("local", true)
|
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
||||||
jsobj.put("server", true)
|
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
||||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
var jsobj = JSObject()
|
||||||
call.resolve(jsobj)
|
jsobj.put("local", true)
|
||||||
// call.resolve(JSObject("{\"local\":true,\"server\":true,\"localMediaProgress\":$lmpstring}"))
|
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}}"))
|
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
|
||||||
var jsobj = JSObject()
|
var jsobj = JSObject()
|
||||||
jsobj.put("local", true)
|
jsobj.put("local", true)
|
||||||
jsobj.put("server", false)
|
jsobj.put("server", false)
|
||||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||||
call.resolve(jsobj)
|
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
|
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
||||||
private fun getFilenameFromRelPath(relPath: String): String {
|
private fun getFilenameFromRelPath(relPath: String): String {
|
||||||
val cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||||
|
cleanedRelPath = cleanStringForFileSystem(cleanedRelPath)
|
||||||
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else 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?) {
|
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
|
||||||
if (libraryItem.mediaType == "book") {
|
if (libraryItem.mediaType == "book") {
|
||||||
val bookTitle = libraryItem.media.metadata.title
|
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||||
|
|
||||||
val tracks = libraryItem.media.getAudioTracks()
|
val tracks = libraryItem.media.getAudioTracks()
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||||
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
|
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
|
||||||
@@ -243,8 +258,8 @@ class AbsDownloader : Plugin() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Podcast episode download
|
// Podcast episode download
|
||||||
|
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||||
|
|
||||||
val podcastTitle = libraryItem.media.metadata.title
|
|
||||||
val audioTrack = episode?.audioTrack
|
val audioTrack = episode?.audioTrack
|
||||||
Log.d(tag, "Starting podcast episode download")
|
Log.d(tag, "Starting podcast episode download")
|
||||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
||||||
@@ -310,6 +325,13 @@ class AbsDownloader : Plugin() {
|
|||||||
delay(500)
|
delay(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove download notifications
|
||||||
|
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||||
|
downloadItemPart.downloadId?.let {
|
||||||
|
downloadManager.remove(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
|
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
|
||||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||||
downloadQueue.remove(downloadItem)
|
downloadQueue.remove(downloadItem)
|
||||||
@@ -352,6 +374,7 @@ class AbsDownloader : Plugin() {
|
|||||||
if (!downloadItemPart.completed) {
|
if (!downloadItemPart.completed) {
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
|
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
|
||||||
downloadItemPart.completed = true
|
downloadItemPart.completed = true
|
||||||
|
|
||||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||||
Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}")
|
Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}")
|
||||||
|
|
||||||
|
|||||||
+275
-40
@@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -12,43 +12,6 @@
|
|||||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
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 {
|
.material-icons {
|
||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -60,9 +23,9 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-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) {
|
.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;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -78,9 +41,9 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-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) {
|
.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;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -93,6 +56,7 @@
|
|||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Gentium Book Basic';
|
font-family: 'Gentium Book Basic';
|
||||||
@@ -101,4 +65,275 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
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;
|
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;
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,9 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
castClick() {
|
castClick() {
|
||||||
if (this.$store.state.playerIsLocal) {
|
if (this.$store.state.playerIsLocal) {
|
||||||
this.$toast.warn('Cannot cast downloaded media item')
|
this.$eventBus.$emit('cast-local-item')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Cast Btn Click')
|
|
||||||
AbsAudioPlayer.requestSession()
|
AbsAudioPlayer.requestSession()
|
||||||
},
|
},
|
||||||
clickShowSideDrawer() {
|
clickShowSideDrawer() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="top-2 left-4 absolute cursor-pointer">
|
<div class="top-2 left-4 absolute cursor-pointer">
|
||||||
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
|
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
|
||||||
</div>
|
</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>
|
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-4 right-4 absolute cursor-pointer">
|
<div class="top-4 right-4 absolute cursor-pointer">
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<span class="material-icons text-3xl">more_vert</span>
|
<span class="material-icons text-3xl">more_vert</span>
|
||||||
</ui-dropdown-menu>
|
</ui-dropdown-menu>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
|
<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">
|
<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 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>
|
<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>
|
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +160,7 @@ export default {
|
|||||||
return this.showFullscreen ? 200 : 60
|
return this.showFullscreen ? 200 : 60
|
||||||
},
|
},
|
||||||
showCastBtn() {
|
showCastBtn() {
|
||||||
return this.$store.state.isCastAvailable && !this.isLocalPlayMethod
|
return this.$store.state.isCastAvailable
|
||||||
},
|
},
|
||||||
isCasting() {
|
isCasting() {
|
||||||
return this.mediaPlayer === 'cast-player'
|
return this.mediaPlayer === 'cast-player'
|
||||||
@@ -193,6 +194,9 @@ export default {
|
|||||||
isLocalPlayMethod() {
|
isLocalPlayMethod() {
|
||||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||||
},
|
},
|
||||||
|
isDirectPlayMethod() {
|
||||||
|
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||||
@@ -269,12 +273,10 @@ export default {
|
|||||||
this.showChapterModal = false
|
this.showChapterModal = false
|
||||||
},
|
},
|
||||||
castClick() {
|
castClick() {
|
||||||
console.log('Cast Btn Click')
|
|
||||||
if (this.isLocalPlayMethod) {
|
if (this.isLocalPlayMethod) {
|
||||||
this.$toast.warn('Cannot cast downloaded media items')
|
this.$eventBus.$emit('cast-local-item')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
AbsAudioPlayer.requestSession()
|
AbsAudioPlayer.requestSession()
|
||||||
},
|
},
|
||||||
clickContainer() {
|
clickContainer() {
|
||||||
|
|||||||
@@ -168,10 +168,36 @@ export default {
|
|||||||
this.$refs.audioPlayer.closePlayback()
|
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) {
|
async playLibraryItem(payload) {
|
||||||
var libraryItemId = payload.libraryItemId
|
var libraryItemId = payload.libraryItemId
|
||||||
var episodeId = payload.episodeId
|
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) {
|
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Warning',
|
title: 'Warning',
|
||||||
@@ -195,6 +221,8 @@ export default {
|
|||||||
console.log('Library item play response', JSON.stringify(data))
|
console.log('Library item play response', JSON.stringify(data))
|
||||||
if (!libraryItemId.startsWith('local')) {
|
if (!libraryItemId.startsWith('local')) {
|
||||||
this.serverLibraryItemId = libraryItemId
|
this.serverLibraryItemId = libraryItemId
|
||||||
|
} else {
|
||||||
|
this.serverLibraryItemId = serverLibraryItemId
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -228,6 +256,7 @@ export default {
|
|||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
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 })
|
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
@@ -246,6 +275,7 @@ export default {
|
|||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||||
|
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||||
this.$store.commit('user/removeSettingsListener', 'streamContainer')
|
this.$store.commit('user/removeSettingsListener', 'streamContainer')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<template v-for="shelf in totalShelves">
|
<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 :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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -446,10 +446,6 @@ export default {
|
|||||||
this.selected = !this.selected
|
this.selected = !this.selected
|
||||||
this.$emit('select', this.libraryItem)
|
this.$emit('select', this.libraryItem)
|
||||||
},
|
},
|
||||||
play() {
|
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
|
||||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
|
|
||||||
},
|
|
||||||
destroy() {
|
destroy() {
|
||||||
// destroy the vue listeners, etc
|
// destroy the vue listeners, etc
|
||||||
this.$destroy()
|
this.$destroy()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
|
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||||
|
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||||
</div>
|
</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` }">
|
<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() {
|
mediaType() {
|
||||||
return this._libraryItem.mediaType
|
return this._libraryItem.mediaType
|
||||||
},
|
},
|
||||||
|
duration() {
|
||||||
|
return this.media.duration || null
|
||||||
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType === 'podcast'
|
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() {
|
placeholderUrl() {
|
||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,9 +27,11 @@
|
|||||||
|
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
<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>
|
<div v-if="!isIos">
|
||||||
<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-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,6 +63,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isIos() {
|
||||||
|
return this.$platform === 'ios'
|
||||||
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return 'podcast'
|
return 'podcast'
|
||||||
},
|
},
|
||||||
@@ -204,9 +209,7 @@ export default {
|
|||||||
var isFinished = !this.userIsFinished
|
var isFinished = !this.userIsFinished
|
||||||
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||||
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||||
var localMediaProgressId = `${localLibraryItemId}-${localEpisodeId}`
|
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||||
console.log('toggleFinished local media progress id', localMediaProgressId, isFinished)
|
|
||||||
var payload = await this.$db.updateLocalMediaProgressFinished({ localMediaProgressId, isFinished })
|
|
||||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||||
if (!payload || payload.error) {
|
if (!payload || payload.error) {
|
||||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
var errorMsg = payload ? payload.error : 'Unknown error'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<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)">
|
<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" />
|
<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" />
|
||||||
|
|||||||
@@ -459,14 +459,14 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 6;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
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\"";
|
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)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "App/App-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "App/App-Bridging-Header.h";
|
||||||
@@ -483,12 +483,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 6;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = "";
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
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_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
|||||||
|
|
||||||
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(seek, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(seek, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(seekForward, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(seekForward, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(seekBackward, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(seekBackward, CAPPluginReturnPromise);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(getCurrentTime, 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);
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ import Capacitor
|
|||||||
|
|
||||||
@objc(AbsAudioPlayer)
|
@objc(AbsAudioPlayer)
|
||||||
public class AbsAudioPlayer: CAPPlugin {
|
public class AbsAudioPlayer: CAPPlugin {
|
||||||
|
private var initialPlayWhenReady = false
|
||||||
|
private var initialPlaybackRate:Float = 1
|
||||||
|
|
||||||
override public func load() {
|
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(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendPlaybackClosedEvent), name: NSNotification.Name(PlayerEvents.closed.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.didBecomeActiveNotification, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.willEnterForegroundNotification, 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) {
|
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
||||||
@@ -33,8 +40,11 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initialPlayWhenReady = playWhenReady
|
||||||
|
initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
|
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)
|
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -43,7 +53,6 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
NSLog("failed to convert session to json")
|
NSLog("failed to convert session to json")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
|
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,18 +77,19 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func playPause(_ call: CAPPluginCall) {
|
|
||||||
PlayerHandler.playPause()
|
|
||||||
call.resolve([ "playing": !PlayerHandler.paused() ])
|
|
||||||
}
|
|
||||||
@objc func playPlayer(_ call: CAPPluginCall) {
|
@objc func playPlayer(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.play()
|
PlayerHandler.paused = false
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
@objc func pausePlayer(_ call: CAPPluginCall) {
|
@objc func pausePlayer(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.pause()
|
PlayerHandler.paused = true
|
||||||
call.resolve()
|
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) {
|
@objc func seek(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.seek(amount: call.getDouble("value", 0.0))
|
PlayerHandler.seek(amount: call.getDouble("value", 0.0))
|
||||||
@@ -95,13 +105,97 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func sendMetadata() {
|
@objc func sendMetadata() {
|
||||||
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused() ])
|
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
||||||
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
|
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
|
||||||
}
|
}
|
||||||
@objc func sendPlaybackClosedEvent() {
|
@objc func sendPlaybackClosedEvent() {
|
||||||
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
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) {
|
@objc func sendPrepareMetadataEvent(itemId: String, playWhenReady: Bool) {
|
||||||
self.notifyListeners("onPrepareMedia", data: [
|
self.notifyListeners("onPrepareMedia", data: [
|
||||||
"audiobookId": itemId,
|
"audiobookId": itemId,
|
||||||
@@ -111,32 +205,4 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||||
self.notifyListeners("onPlaybackSession", data: session)
|
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
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
}
|
}
|
||||||
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
let id = call.getString("serverConnectionConfigId", "")
|
let id = call.getString("serverConnectionConfigId", "")
|
||||||
Database.deleteServerConnectionConfig(id: id)
|
Database.shared.deleteServerConnectionConfig(id: id)
|
||||||
|
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
@@ -63,13 +63,12 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func getDeviceData(_ call: CAPPluginCall) {
|
@objc func getDeviceData(_ call: CAPPluginCall) {
|
||||||
let configs = Database.getServerConnectionConfigs()
|
let configs = Database.shared.getServerConnectionConfigs()
|
||||||
let index = Database.getLastActiveConfigIndex()
|
let index = Database.shared.getLastActiveConfigIndex()
|
||||||
|
|
||||||
call.resolve([
|
call.resolve([
|
||||||
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
|
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
|
||||||
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id,
|
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
|
||||||
// Luckily this isn't implemented yet
|
|
||||||
// "currentLocalPlaybackSession": nil,
|
// "currentLocalPlaybackSession": nil,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import AVFoundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
|
|
||||||
|
enum PlayMethod:Int {
|
||||||
|
case directplay = 0
|
||||||
|
case directstream = 1
|
||||||
|
case transcode = 2
|
||||||
|
case local = 3
|
||||||
|
}
|
||||||
|
|
||||||
class AudioPlayer: NSObject {
|
class AudioPlayer: NSObject {
|
||||||
// enums and @objc are not compatible
|
// enums and @objc are not compatible
|
||||||
@objc dynamic var status: Int
|
@objc dynamic var status: Int
|
||||||
@@ -24,29 +31,25 @@ class AudioPlayer: NSObject {
|
|||||||
private var playWhenReady: Bool
|
private var playWhenReady: Bool
|
||||||
private var initialPlaybackRate: Float
|
private var initialPlaybackRate: Float
|
||||||
|
|
||||||
private var audioPlayer: AVPlayer
|
private var audioPlayer: AVQueuePlayer
|
||||||
private var playbackSession: PlaybackSession
|
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
|
// MARK: - Constructor
|
||||||
init(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
init(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
||||||
self.playWhenReady = playWhenReady
|
self.playWhenReady = playWhenReady
|
||||||
self.initialPlaybackRate = playbackRate
|
self.initialPlaybackRate = playbackRate
|
||||||
self.audioPlayer = AVPlayer()
|
self.audioPlayer = AVQueuePlayer()
|
||||||
self.playbackSession = playbackSession
|
self.playbackSession = playbackSession
|
||||||
self.status = -1
|
self.status = -1
|
||||||
self.rate = 0.0
|
self.rate = 0.0
|
||||||
self.tmpRate = playbackRate
|
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()
|
super.init()
|
||||||
|
|
||||||
initAudioSession()
|
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.rate), options: .new, context: &playerContext)
|
||||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
||||||
|
|
||||||
let playerItem = AVPlayerItem(asset: createAsset())
|
for track in playbackSession.audioTracks {
|
||||||
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: .new, context: &playerItemContext)
|
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")
|
NSLog("Audioplayer ready")
|
||||||
}
|
}
|
||||||
deinit {
|
deinit {
|
||||||
|
self.queueObserver?.invalidate()
|
||||||
|
self.queueItemStatusObserver?.invalidate()
|
||||||
destroy()
|
destroy()
|
||||||
}
|
}
|
||||||
public func destroy() {
|
public func destroy() {
|
||||||
// Pause is not synchronous causing this error on below lines:
|
// 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
|
// 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()
|
pause()
|
||||||
audioPlayer.replaceCurrentItem(with: nil)
|
audioPlayer.replaceCurrentItem(with: nil)
|
||||||
|
|
||||||
@@ -79,14 +98,60 @@ class AudioPlayer: NSObject {
|
|||||||
print(error)
|
print(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throws error Possibly related to the error above
|
DispatchQueue.runOnMainQueue {
|
||||||
// DispatchQueue.main.sync {
|
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||||
// UIApplication.shared.endReceivingRemoteControlEvents()
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
|
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
|
// MARK: - Methods
|
||||||
public func play(allowSeekBack: Bool = false) {
|
public func play(allowSeekBack: Bool = false) {
|
||||||
if allowSeekBack {
|
if allowSeekBack {
|
||||||
@@ -110,11 +175,11 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if time != nil {
|
if time != nil {
|
||||||
seek(getCurrentTime() - Double(time!))
|
seek(getCurrentTime() - Double(time!), from: "play")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastPlayTime = Date.timeIntervalSinceReferenceDate
|
lastPlayTime = Date.timeIntervalSinceReferenceDate
|
||||||
|
|
||||||
self.audioPlayer.play()
|
self.audioPlayer.play()
|
||||||
self.status = 1
|
self.status = 1
|
||||||
self.rate = self.tmpRate
|
self.rate = self.tmpRate
|
||||||
@@ -122,6 +187,7 @@ class AudioPlayer: NSObject {
|
|||||||
|
|
||||||
updateNowPlaying()
|
updateNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pause() {
|
public func pause() {
|
||||||
self.audioPlayer.pause()
|
self.audioPlayer.pause()
|
||||||
self.status = 0
|
self.status = 0
|
||||||
@@ -130,24 +196,60 @@ class AudioPlayer: NSObject {
|
|||||||
updateNowPlaying()
|
updateNowPlaying()
|
||||||
lastPlayTime = Date.timeIntervalSinceReferenceDate
|
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()
|
pause()
|
||||||
self.audioPlayer.seek(to: CMTime(seconds: to, preferredTimescale: 1000)) { completed in
|
|
||||||
if !completed {
|
NSLog("TEST: Seek to \(to) from \(from)")
|
||||||
NSLog("WARNING: seeking not completed (to \(to)")
|
|
||||||
|
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 {
|
setupQueueItemStatusObserver()
|
||||||
self.play()
|
} 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) {
|
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
|
||||||
if self.audioPlayer.rate != rate {
|
if self.audioPlayer.rate != rate {
|
||||||
|
NSLog("TEST: setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
||||||
self.audioPlayer.rate = rate
|
self.audioPlayer.rate = rate
|
||||||
}
|
}
|
||||||
if rate > 0.0 && !(observed && rate == 1) {
|
if rate > 0.0 && !(observed && rate == 1) {
|
||||||
@@ -155,25 +257,42 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.rate = rate
|
self.rate = rate
|
||||||
|
|
||||||
self.updateNowPlaying()
|
self.updateNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getCurrentTime() -> Double {
|
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 {
|
public func getDuration() -> Double {
|
||||||
self.audioPlayer.currentItem?.duration.seconds ?? 0
|
return playbackSession.duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
private func createAsset() -> AVAsset {
|
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset {
|
||||||
let headers: [String: String] = [
|
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
|
||||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
// 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)
|
||||||
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(activeAudioTrack.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
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() {
|
private func initAudioSession() {
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay])
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay])
|
||||||
@@ -186,9 +305,9 @@ class AudioPlayer: NSObject {
|
|||||||
|
|
||||||
// MARK: - Now playing
|
// MARK: - Now playing
|
||||||
private func setupRemoteTransportControls() {
|
private func setupRemoteTransportControls() {
|
||||||
// DispatchQueue.main.sync {
|
DispatchQueue.runOnMainQueue {
|
||||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||||
// }
|
}
|
||||||
let commandCenter = MPRemoteCommandCenter.shared()
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
commandCenter.playCommand.isEnabled = true
|
commandCenter.playCommand.isEnabled = true
|
||||||
@@ -209,7 +328,7 @@ class AudioPlayer: NSObject {
|
|||||||
return .noSuchContent
|
return .noSuchContent
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue)
|
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue, from: "remote")
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
commandCenter.skipBackwardCommand.isEnabled = true
|
commandCenter.skipBackwardCommand.isEnabled = true
|
||||||
@@ -219,7 +338,7 @@ class AudioPlayer: NSObject {
|
|||||||
return .noSuchContent
|
return .noSuchContent
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue)
|
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue, from: "remote")
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +348,7 @@ class AudioPlayer: NSObject {
|
|||||||
return .noSuchContent
|
return .noSuchContent
|
||||||
}
|
}
|
||||||
|
|
||||||
self.seek(event.positionTime)
|
self.seek(event.positionTime, from: "remote")
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,33 +365,17 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
private func updateNowPlaying() {
|
private func updateNowPlaying() {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
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
|
// MARK: - Observer
|
||||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
if context == &playerItemContext {
|
if context == &playerContext {
|
||||||
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 keyPath == #keyPath(AVPlayer.rate) {
|
if keyPath == #keyPath(AVPlayer.rate) {
|
||||||
|
NSLog("TEST: playerContext observer player rate")
|
||||||
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
|
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||||
} else if keyPath == #keyPath(AVPlayer.currentItem) {
|
} else if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||||
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
NSLog("WARNING: Item ended")
|
NSLog("WARNING: Item ended")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,7 +12,44 @@ class PlayerHandler {
|
|||||||
private static var session: PlaybackSession?
|
private static var session: PlaybackSession?
|
||||||
private static var timer: Timer?
|
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) {
|
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
|
||||||
if player != nil {
|
if player != nil {
|
||||||
@@ -20,16 +57,16 @@ class PlayerHandler {
|
|||||||
player = nil
|
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
|
self.session = session
|
||||||
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
|
||||||
// DispatchQueue.main.sync {
|
DispatchQueue.runOnMainQueue {
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||||
self.tick()
|
self.tick()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
public static func stopPlayback() {
|
public static func stopPlayback() {
|
||||||
player?.destroy()
|
player?.destroy()
|
||||||
@@ -38,7 +75,7 @@ class PlayerHandler {
|
|||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
|
|
||||||
NowPlayingInfo.reset()
|
NowPlayingInfo.shared.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getCurrentTime() -> Double? {
|
public static func getCurrentTime() -> Double? {
|
||||||
@@ -47,45 +84,32 @@ class PlayerHandler {
|
|||||||
public static func setPlaybackSpeed(speed: Float) {
|
public static func setPlaybackSpeed(speed: Float) {
|
||||||
self.player?.setPlaybackRate(speed)
|
self.player?.setPlaybackRate(speed)
|
||||||
}
|
}
|
||||||
|
public static func getPlayMethod() -> Int? {
|
||||||
public static func play() {
|
self.player?.getPlayMethod()
|
||||||
self.player?.play()
|
|
||||||
}
|
}
|
||||||
public static func pause() {
|
public static func getPlaybackSession() -> PlaybackSession? {
|
||||||
self.player?.play()
|
self.player?.getPlaybackSession()
|
||||||
}
|
|
||||||
public static func playPause() {
|
|
||||||
if paused() {
|
|
||||||
self.player?.play()
|
|
||||||
} else {
|
|
||||||
self.player?.pause()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func seekForward(amount: Double) {
|
public static func seekForward(amount: Double) {
|
||||||
if player == nil {
|
guard let player = player else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let destinationTime = player!.getCurrentTime() + amount
|
let destinationTime = player.getCurrentTime() + amount
|
||||||
player!.seek(destinationTime)
|
player.seek(destinationTime, from: "handler")
|
||||||
}
|
}
|
||||||
public static func seekBackward(amount: Double) {
|
public static func seekBackward(amount: Double) {
|
||||||
if player == nil {
|
guard let player = player else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let destinationTime = player!.getCurrentTime() - amount
|
let destinationTime = player.getCurrentTime() - amount
|
||||||
player!.seek(destinationTime)
|
player.seek(destinationTime, from: "handler")
|
||||||
}
|
}
|
||||||
public static func seek(amount: Double) {
|
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] {
|
public static func getMetdata() -> [String: Any] {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
syncProgress()
|
syncProgress()
|
||||||
@@ -94,29 +118,42 @@ class PlayerHandler {
|
|||||||
return [
|
return [
|
||||||
"duration": player?.getDuration() ?? 0,
|
"duration": player?.getDuration() ?? 0,
|
||||||
"currentTime": player?.getCurrentTime() ?? 0,
|
"currentTime": player?.getCurrentTime() ?? 0,
|
||||||
"playerState": !paused(),
|
"playerState": !paused,
|
||||||
"currentRate": player?.rate ?? 0,
|
"currentRate": player?.rate ?? 0,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func tick() {
|
private static func tick() {
|
||||||
if !paused() {
|
if !paused {
|
||||||
listeningTimePassedSinceLastSync += 1
|
listeningTimePassedSinceLastSync += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if listeningTimePassedSinceLastSync > 3 {
|
if listeningTimePassedSinceLastSync > 3 {
|
||||||
syncProgress()
|
syncProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if remainingSleepTime != nil {
|
||||||
|
if remainingSleepTime! == 0 {
|
||||||
|
paused = true
|
||||||
|
}
|
||||||
|
remainingSleepTime! -= 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public static func syncProgress() {
|
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
|
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
|
listeningTimePassedSinceLastSync = 0
|
||||||
|
lastSyncReport = report
|
||||||
|
|
||||||
// TODO: check if online
|
// TODO: check if online
|
||||||
NSLog("sending playback report")
|
NSLog("sending playback report")
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import Foundation
|
|||||||
import Alamofire
|
import Alamofire
|
||||||
|
|
||||||
class ApiClient {
|
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)?) {
|
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: String], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
NSLog("Server config not set")
|
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"
|
var endpoint = "api/items/\(libraryItemId)/play"
|
||||||
if episodeId != nil {
|
if episodeId != nil {
|
||||||
endpoint += "/\(episodeId!)"
|
endpoint += "/\(episodeId!)"
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient.postResource(endpoint: endpoint, parameters: [
|
ApiClient.postResource(endpoint: endpoint, parameters: [
|
||||||
"forceTranscode": "true", // TODO: direct play
|
"forceDirectPlay": !forceTranscode ? "1" : "",
|
||||||
|
"forceTranscode": forceTranscode ? "1" : "",
|
||||||
"mediaPlayer": "AVPlayer",
|
"mediaPlayer": "AVPlayer",
|
||||||
], decodable: PlaybackSession.self) { obj in
|
], decodable: PlaybackSession.self) { obj in
|
||||||
var session = obj
|
var session = obj
|
||||||
|
|||||||
@@ -10,16 +10,25 @@ import RealmSwift
|
|||||||
|
|
||||||
class Database {
|
class Database {
|
||||||
// All DB releated actions must be executed on "realm-queue"
|
// All DB releated actions must be executed on "realm-queue"
|
||||||
public static let realmQueue = DispatchQueue(label: "realm-queue")
|
public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue")
|
||||||
private static var instance: Realm = try! Realm(queue: realmQueue)
|
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>?
|
var refrence: ThreadSafeReference<ServerConnectionConfig>?
|
||||||
if config.realm != nil {
|
if config.realm != nil {
|
||||||
refrence = ThreadSafeReference(to: config)
|
refrence = ThreadSafeReference(to: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
realmQueue.sync {
|
Database.realmQueue.sync {
|
||||||
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
|
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
|
||||||
|
|
||||||
if config.index == 0 {
|
if config.index == 0 {
|
||||||
@@ -55,8 +64,8 @@ class Database {
|
|||||||
setLastActiveConfigIndex(index: config.index)
|
setLastActiveConfigIndex(index: config.index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static func deleteServerConnectionConfig(id: String) {
|
public func deleteServerConnectionConfig(id: String) {
|
||||||
realmQueue.sync {
|
Database.realmQueue.sync {
|
||||||
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
|
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -71,10 +80,10 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static func getServerConnectionConfigs() -> [ServerConnectionConfig] {
|
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
|
||||||
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
|
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
|
||||||
|
|
||||||
realmQueue.sync {
|
Database.realmQueue.sync {
|
||||||
let configs = instance.objects(ServerConnectionConfig.self)
|
let configs = instance.objects(ServerConnectionConfig.self)
|
||||||
refrences = configs.map { config in
|
refrences = configs.map { config in
|
||||||
return ThreadSafeReference(to: config)
|
return ThreadSafeReference(to: config)
|
||||||
@@ -94,12 +103,12 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func setLastActiveConfigIndexToNil() {
|
public func setLastActiveConfigIndexToNil() {
|
||||||
realmQueue.sync {
|
Database.realmQueue.sync {
|
||||||
setLastActiveConfigIndex(index: nil)
|
setLastActiveConfigIndex(index: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static func setLastActiveConfigIndex(index: Int?) {
|
public func setLastActiveConfigIndex(index: Int?) {
|
||||||
let existing = instance.objects(ServerConnectionConfigActiveIndex.self)
|
let existing = instance.objects(ServerConnectionConfigActiveIndex.self)
|
||||||
let obj = ServerConnectionConfigActiveIndex()
|
let obj = ServerConnectionConfigActiveIndex()
|
||||||
obj.index = index
|
obj.index = index
|
||||||
@@ -114,8 +123,8 @@ class Database {
|
|||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static func getLastActiveConfigIndex() -> Int? {
|
public func getLastActiveConfigIndex() -> Int? {
|
||||||
return realmQueue.sync {
|
return Database.realmQueue.sync {
|
||||||
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,3 +18,14 @@ extension Encodable {
|
|||||||
return dictionary
|
return dictionary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
extension DispatchQueue {
|
||||||
|
static func runOnMainQueue(callback: @escaping (() -> Void)) {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
callback()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,14 +8,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MediaPlayer
|
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 {
|
struct NowPlayingMetadata {
|
||||||
var id: String
|
var id: String
|
||||||
var itemId: String
|
var itemId: String
|
||||||
@@ -26,22 +18,22 @@ struct NowPlayingMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class NowPlayingInfo {
|
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)
|
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 {
|
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ApiClient.getData(from: url) { [self] image in
|
||||||
getData(from: url) { [self] image in
|
|
||||||
guard let downloadedImage = image else {
|
guard let downloadedImage = image else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -52,7 +44,7 @@ class NowPlayingInfo {
|
|||||||
self.setMetadata(artwork: artwork, metadata: metadata)
|
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[MPMediaItemPropertyPlaybackDuration] = duration
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
||||||
@@ -60,12 +52,12 @@ class NowPlayingInfo {
|
|||||||
|
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||||
}
|
}
|
||||||
public static func reset() {
|
public func reset() {
|
||||||
nowPlayingInfo = [:]
|
nowPlayingInfo = [:]
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) {
|
private func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,7 +76,7 @@ class NowPlayingInfo {
|
|||||||
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
||||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
|
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
|
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ import Foundation
|
|||||||
enum PlayerEvents: String {
|
enum PlayerEvents: String {
|
||||||
case update = "com.audiobookshelf.app.player.update"
|
case update = "com.audiobookshelf.app.player.update"
|
||||||
case closed = "com.audiobookshelf.app.player.closed"
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ class Store {
|
|||||||
}
|
}
|
||||||
set(updated) {
|
set(updated) {
|
||||||
if updated != nil {
|
if updated != nil {
|
||||||
Database.setServerConnectionConfig(config: updated!)
|
Database.shared.setServerConnectionConfig(config: updated!)
|
||||||
} else {
|
} else {
|
||||||
Database.setLastActiveConfigIndexToNil()
|
Database.shared.setLastActiveConfigIndexToNil()
|
||||||
}
|
}
|
||||||
|
|
||||||
Database.realmQueue.sync {
|
Database.realmQueue.sync {
|
||||||
|
|||||||
+1
-2
@@ -30,8 +30,7 @@ export default {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
{ 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' },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.43-beta",
|
"version": "0.9.44-beta",
|
||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt --hostname localhost --port 1337",
|
"dev": "nuxt --hostname localhost --port 1337",
|
||||||
|
|||||||
+81
-12
@@ -6,10 +6,13 @@
|
|||||||
<covers-book-cover :library-item="libraryItem" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<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 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>
|
</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>
|
||||||
<div class="flex-grow px-3">
|
<div class="flex-grow px-3">
|
||||||
<h1 class="text-lg">{{ title }}</h1>
|
<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 class="text-sm text-gray-400">by {{ author }}</p>
|
||||||
<p v-if="numTracks" class="text-gray-300 text-sm my-1">
|
<p v-if="numTracks" class="text-gray-300 text-sm my-1">
|
||||||
{{ $elapsedPretty(duration) }}
|
{{ $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' : ''">
|
<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 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-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">
|
<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>
|
<span class="material-icons text-sm">close</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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 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>
|
||||||
<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 class="material-icons">auto_stories</span>
|
||||||
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
||||||
</ui-btn>
|
</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 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">
|
<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 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>
|
||||||
<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">
|
<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 class="material-icons">auto_stories</span>
|
||||||
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
||||||
</ui-btn>
|
</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>
|
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||||
</ui-btn>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +109,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
resettingProgress: false,
|
resettingProgress: false,
|
||||||
|
isProcessingReadUpdate: false,
|
||||||
showSelectLocalFolder: false
|
showSelectLocalFolder: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -129,8 +136,14 @@ export default {
|
|||||||
var podcastMedia = this.localLibraryItem.media
|
var podcastMedia = this.localLibraryItem.media
|
||||||
return podcastMedia ? podcastMedia.episodes || [] : []
|
return podcastMedia ? podcastMedia.episodes || [] : []
|
||||||
},
|
},
|
||||||
isConnected() {
|
serverLibraryItemId() {
|
||||||
return this.$store.state.socketConnected
|
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() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
@@ -163,6 +176,10 @@ export default {
|
|||||||
series() {
|
series() {
|
||||||
return this.mediaMetadata.series || []
|
return this.mediaMetadata.series || []
|
||||||
},
|
},
|
||||||
|
seriesName() {
|
||||||
|
// For books only on toJSONExpanded
|
||||||
|
return this.mediaMetadata.seriesName || ''
|
||||||
|
},
|
||||||
duration() {
|
duration() {
|
||||||
return this.media.duration
|
return this.media.duration
|
||||||
},
|
},
|
||||||
@@ -233,6 +250,9 @@ export default {
|
|||||||
},
|
},
|
||||||
episodes() {
|
episodes() {
|
||||||
return this.media.episodes || []
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
isCasting() {
|
||||||
|
return this.$store.state.isCasting
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -241,7 +261,12 @@ export default {
|
|||||||
},
|
},
|
||||||
playClick() {
|
playClick() {
|
||||||
// Todo: Allow playing local or streaming
|
// 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 })
|
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
|
||||||
},
|
},
|
||||||
async clearProgressClick() {
|
async clearProgressClick() {
|
||||||
@@ -352,15 +377,59 @@ export default {
|
|||||||
console.log('New local library item', item.id)
|
console.log('New local library item', item.id)
|
||||||
this.$set(this.libraryItem, 'localLibraryItem', item)
|
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() {
|
mounted() {
|
||||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
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() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
|
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>
|
</script>
|
||||||
@@ -199,7 +199,8 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLocalMediaProgressFinished({ localMediaProgressId, isFinished }) {
|
async updateLocalMediaProgressFinished(payload) {
|
||||||
|
// { localLibraryItemId, localEpisodeId, isFinished }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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.
Reference in New Issue
Block a user