Merge branch 'master' into ios-downloads
|
@ -33,8 +33,8 @@ android {
|
|||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 86
|
||||
versionName "0.9.55-beta"
|
||||
versionCode 87
|
||||
versionName "0.9.56-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
@ -121,7 +121,7 @@ dependencies {
|
|||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||
|
||||
// FFMPEG-Kit
|
||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
||||
|
|
|
@ -59,8 +59,6 @@ data class LibraryItem(
|
|||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
||||
}.build()
|
||||
}
|
||||
|
@ -309,7 +307,6 @@ data class PodcastEpisode(
|
|||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, podcast.metadata.getAuthorDisplayName())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
|
||||
|
@ -407,18 +404,25 @@ data class BookChapter(
|
|||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgress(
|
||||
class MediaProgress(
|
||||
var id:String,
|
||||
var libraryItemId:String,
|
||||
var episodeId:String?,
|
||||
var duration:Double, // seconds
|
||||
var progress:Double, // 0 to 1
|
||||
progress:Double, // 0 to 1
|
||||
var currentTime:Double,
|
||||
var isFinished:Boolean,
|
||||
isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
) : MediaProgressWrapper(isFinished, progress)
|
||||
|
||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(MediaProgress::class),
|
||||
JsonSubTypes.Type(LocalMediaProgress::class)
|
||||
)
|
||||
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double)
|
||||
|
||||
// Helper class
|
||||
data class LibraryItemWithEpisode(
|
||||
|
|
|
@ -20,7 +20,8 @@ data class DeviceSettings(
|
|||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int
|
||||
var jumpForwardTime:Int,
|
||||
var disableShakeToResetSleepTimer:Boolean
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
|
@ -29,7 +30,8 @@ data class DeviceSettings(
|
|||
disableAutoRewind = false,
|
||||
enableAltView = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10
|
||||
jumpForwardTime = 10,
|
||||
disableShakeToResetSleepTimer = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,11 @@ package com.audiobookshelf.app.data
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
@ -95,7 +98,7 @@ data class LocalLibraryItem(
|
|||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(ctx: Context): MediaMetadataCompat {
|
||||
fun getMediaMetadata(): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri()
|
||||
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
|
@ -104,8 +107,6 @@ data class LocalLibraryItem(
|
|||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
||||
}.build()
|
||||
}
|
||||
|
|
|
@ -5,14 +5,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalMediaProgress(
|
||||
class LocalMediaProgress(
|
||||
var id:String,
|
||||
var localLibraryItemId:String,
|
||||
var localEpisodeId:String?,
|
||||
var duration:Double,
|
||||
var progress:Double, // 0 to 1
|
||||
progress:Double, // 0 to 1
|
||||
var currentTime:Double,
|
||||
var isFinished:Boolean,
|
||||
isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?,
|
||||
|
@ -22,7 +22,7 @@ data class LocalMediaProgress(
|
|||
var serverUserId:String?,
|
||||
var libraryItemId:String?,
|
||||
var episodeId:String?
|
||||
) {
|
||||
) : MediaProgressWrapper(isFinished, progress) {
|
||||
@get:JsonIgnore
|
||||
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
||||
|
||||
|
|
|
@ -91,6 +91,7 @@ class PlaybackSession(
|
|||
|
||||
@JsonIgnore
|
||||
fun getTrackStartOffsetMs(index:Int):Long {
|
||||
if (index < 0 || index >= audioTracks.size) return 0L
|
||||
val currentTrack = audioTracks[index]
|
||||
return (currentTrack.startOffset * 1000L).toLong()
|
||||
}
|
||||
|
@ -123,8 +124,6 @@ class PlaybackSession(
|
|||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.fasterxml.jackson.core.json.JsonReadFeature
|
|||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.JSObject
|
||||
import org.json.JSONException
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
|
@ -465,11 +466,17 @@ class FolderScanner(var ctx: Context) {
|
|||
|
||||
fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
|
||||
|
||||
val probeObject = JSObject(session.output)
|
||||
if (!probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath is empty")
|
||||
var probeObject:JSObject? = null
|
||||
try {
|
||||
probeObject = JSObject(session.output)
|
||||
} catch(error:JSONException) {
|
||||
Log.e(tag, "Failed to parse probe result $error")
|
||||
}
|
||||
|
||||
Log.d(tag, "FFprobe output $probeObject")
|
||||
if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||
return null
|
||||
} else {
|
||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||
|
|
|
@ -2,8 +2,12 @@ package com.audiobookshelf.app.media
|
|||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
|
@ -18,7 +22,8 @@ import kotlin.coroutines.suspendCoroutine
|
|||
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val tag = "MediaManager"
|
||||
|
||||
var serverLibraryItems = mutableListOf<LibraryItem>()
|
||||
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||
var selectedLibraryId = ""
|
||||
|
||||
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
|
||||
|
@ -28,6 +33,8 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
var serverConfigIdUsed:String? = null
|
||||
var serverConfigLastPing:Long = 0L
|
||||
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
|
@ -68,6 +75,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
selectedLibraryItems = mutableListOf()
|
||||
selectedLibraryId = ""
|
||||
}
|
||||
}
|
||||
|
@ -84,14 +92,18 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
|
||||
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
||||
cb(serverLibraryItems)
|
||||
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
||||
cb(selectedLibraryItems)
|
||||
} else {
|
||||
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||
if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId
|
||||
if (libraryItemsWithAudio.isNotEmpty()) {
|
||||
selectedLibraryId = libraryId
|
||||
}
|
||||
|
||||
selectedLibraryItems = mutableListOf()
|
||||
libraryItemsWithAudio.forEach { libraryItem ->
|
||||
selectedLibraryItems.add(libraryItem)
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
|
@ -132,7 +144,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
|
||||
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
}
|
||||
|
@ -147,7 +163,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
|
||||
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
|
||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
}
|
||||
|
@ -179,16 +199,49 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
return successfulPing
|
||||
}
|
||||
|
||||
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
if (!apiHandler.isOnline()) cb(false)
|
||||
else {
|
||||
coroutineScope {
|
||||
var hasValidConn = false
|
||||
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
||||
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||
suspendCoroutine<MutableList<MediaProgress>> { cont ->
|
||||
apiHandler.authorize(config) {
|
||||
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
|
||||
if (!it.isNullOrEmpty()) {
|
||||
mediaProgress = it
|
||||
}
|
||||
cont.resume(mediaProgress)
|
||||
}
|
||||
}
|
||||
return mediaProgress
|
||||
}
|
||||
|
||||
// First check if the current selected config is pingable
|
||||
DeviceManager.serverConnectionConfig?.let {
|
||||
hasValidConn = checkServerConnection(it)
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn")
|
||||
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
|
||||
coroutineScope {
|
||||
if (!apiHandler.isOnline()) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
var hasValidConn = false
|
||||
var lookupMediaProgress = true
|
||||
|
||||
if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
|
||||
hasValidConn = true
|
||||
lookupMediaProgress = false
|
||||
} else {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
}
|
||||
|
||||
if (!hasValidConn) {
|
||||
// First check if the current selected config is pingable
|
||||
DeviceManager.serverConnectionConfig?.let {
|
||||
hasValidConn = checkServerConnection(it)
|
||||
Log.d(
|
||||
tag,
|
||||
"checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidConn) {
|
||||
|
@ -205,9 +258,21 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
if (hasValidConn) {
|
||||
serverConfigLastPing = System.currentTimeMillis()
|
||||
|
||||
if (lookupMediaProgress) {
|
||||
Log.d(tag, "Has valid conn now get user media progress")
|
||||
DeviceManager.serverConnectionConfig?.let {
|
||||
serverUserMediaProgress = authorize(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cb(hasValidConn)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TODO: Load currently listening category for local items
|
||||
|
@ -259,6 +324,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
|
||||
if (it.type == library.mediaType) {
|
||||
// Log.d(tag, "Using library category ${it.id}")
|
||||
|
@ -328,6 +394,41 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getMediaDescriptionFromMediaMetadata(item: MediaMetadataCompat, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
|
||||
val extras = Bundle()
|
||||
if (progress != null) {
|
||||
Log.d(tag, "Has media progress for ${item.description.title} | ${progress}")
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "No media progress for ${item.description.title} | ${item.description.mediaId}")
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(item.description.mediaId)
|
||||
.setTitle(item.description.title)
|
||||
.setIconUri(item.description.iconUri)
|
||||
.setSubtitle(item.description.subtitle)
|
||||
.setExtras(extras).build()
|
||||
}
|
||||
|
||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
||||
val lhsLength = lhs.length + 1
|
||||
val rhsLength = rhs.length + 1
|
||||
|
|
|
@ -6,10 +6,7 @@ import android.net.Uri
|
|||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.annotation.AnyRes
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.Library
|
||||
import com.audiobookshelf.app.data.LibraryCategory
|
||||
import com.audiobookshelf.app.data.LibraryItem
|
||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
||||
import com.audiobookshelf.app.data.*
|
||||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
|
@ -85,7 +82,7 @@ class BrowseTree(
|
|||
localBooksCat.entities.forEach { libc ->
|
||||
val libraryItem = libc as LocalLibraryItem
|
||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
children += libraryItem.getMediaMetadata(context)
|
||||
children += libraryItem.getMediaMetadata()
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
@ -94,7 +91,7 @@ class BrowseTree(
|
|||
localPodcastsCat.entities.forEach { libc ->
|
||||
val libraryItem = libc as LocalLibraryItem
|
||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
children += libraryItem.getMediaMetadata(context)
|
||||
children += libraryItem.getMediaMetadata()
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,14 +74,14 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||
}
|
||||
}
|
||||
|
||||
fun stop(cb: () -> Unit) {
|
||||
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) return
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||
sync(true, currentTime) {
|
||||
reset()
|
||||
|
|
|
@ -29,9 +29,14 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
|||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||
|
||||
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
|
||||
if (!PlayerNotificationService.isClosed) {
|
||||
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
|
||||
Log.d(tag, "PNS is not closed - closing it now")
|
||||
playerNotificationService.closePlayback()
|
||||
} else if (PlayerNotificationService.isSwitchingPlayer) {
|
||||
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
||||
// so we don't want to cancel the playback during this switch
|
||||
Log.d(tag, "PNS is switching player")
|
||||
PlayerNotificationService.isSwitchingPlayer = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
var isStarted = false
|
||||
var isClosed = false
|
||||
var isUnmeteredNetwork = false
|
||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||
}
|
||||
|
||||
interface ClientEventEmitter {
|
||||
|
@ -147,6 +148,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
|
||||
// detach player
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
} catch(error:Exception) {
|
||||
Log.e(tag, "Error unregistering network listening callback $error")
|
||||
}
|
||||
|
||||
Log.d(tag, "onDestroy")
|
||||
playerNotificationManager.setPlayer(null)
|
||||
mPlayer.release()
|
||||
castPlayer?.release()
|
||||
|
@ -255,11 +264,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
|
||||
// Fix for local images crashing on Android 11 for specific devices
|
||||
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||
ctx.grantUriPermission(
|
||||
"com.android.systemui",
|
||||
coverUri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
try {
|
||||
ctx.grantUriPermission(
|
||||
"com.android.systemui",
|
||||
coverUri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
} catch(error:Exception) {
|
||||
Log.e(tag, "Grant uri permission error $error")
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(currentPlaybackSession!!.id)
|
||||
|
@ -309,8 +322,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
|
||||
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
|
||||
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
|
||||
Log.d(tag, "Seek Back Time $seekBackTime")
|
||||
Log.d(tag, "Seek Forward Time $seekForwardTime")
|
||||
|
||||
mPlayer = ExoPlayer.Builder(this)
|
||||
.setLoadControl(customLoadControl)
|
||||
|
@ -413,7 +424,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
currentPlayer.setPlaybackSpeed(playbackRateToUse)
|
||||
|
||||
currentPlayer.prepare()
|
||||
|
||||
} else if (castPlayer != null) {
|
||||
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
||||
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
|
||||
|
@ -436,7 +446,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) { // Play request failed
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback()
|
||||
closePlayback(true)
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
|
@ -445,7 +455,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
}
|
||||
} else {
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback()
|
||||
closePlayback(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -489,6 +499,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
if (currentPlaybackSession == null) {
|
||||
Log.e(tag, "switchToPlayer: No Current playback session")
|
||||
} else {
|
||||
isSwitchingPlayer = true
|
||||
}
|
||||
|
||||
// Playback session in progress syncer is a copy that is up-to-date so replace current here with that
|
||||
// TODO: bad design here implemented to prevent the session in MediaProgressSyncer from changing while syncing
|
||||
if (mediaProgressSyncer.currentPlaybackSession != null) {
|
||||
currentPlaybackSession = mediaProgressSyncer.currentPlaybackSession?.clone()
|
||||
}
|
||||
|
||||
currentPlayer = if (useCastPlayer) {
|
||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
||||
mediaSessionConnector.setPlayer(castPlayer)
|
||||
|
@ -503,15 +525,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
|
||||
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
|
||||
|
||||
if (currentPlaybackSession == null) {
|
||||
Log.d(tag, "switchToPlayer: No Current playback session")
|
||||
}
|
||||
|
||||
currentPlaybackSession?.let {
|
||||
Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}")
|
||||
Log.d(tag, "switchToPlayer: Starting new playback session ${it.displayTitle}")
|
||||
if (wasPlaying) { // media is paused when switching players
|
||||
clientEventEmitter?.onPlayingUpdate(false)
|
||||
}
|
||||
|
||||
// TODO: Start a new playback session here instead of using the existing
|
||||
preparePlayer(it, false, null)
|
||||
}
|
||||
}
|
||||
|
@ -548,10 +568,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
return currentPlaybackSession?.totalDurationMs ?: 0L
|
||||
}
|
||||
|
||||
fun getCurrentBookTitle() : String? {
|
||||
return currentPlaybackSession?.displayTitle
|
||||
}
|
||||
|
||||
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
||||
return currentPlaybackSession?.clone()
|
||||
}
|
||||
|
@ -656,10 +672,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
return
|
||||
}
|
||||
currentPlayer.volume = 1F
|
||||
if (currentPlayer == castPlayer) {
|
||||
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
|
||||
}
|
||||
|
||||
currentPlayer.play()
|
||||
}
|
||||
|
||||
|
@ -701,12 +713,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
currentPlayer.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
fun closePlayback() {
|
||||
fun closePlayback(calledOnError:Boolean? = false) {
|
||||
Log.d(tag, "closePlayback")
|
||||
if (mediaProgressSyncer.listeningTimerRunning) {
|
||||
Log.i(tag, "About to close playback so stopping media progress syncer first")
|
||||
mediaProgressSyncer.stop {
|
||||
Log.d(tag, "Media Progress syncer stopped and synced")
|
||||
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
|
||||
Log.d(tag, "Media Progress syncer stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -816,7 +828,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
||||
|
||||
var flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
val flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
|
||||
result.detach()
|
||||
|
||||
|
@ -832,10 +844,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
|
||||
|
||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
} else {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemMediaMetadata.description.mediaId }
|
||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(libraryItemMediaMetadata, progress)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, flag)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
|
@ -846,26 +860,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||
|
||||
localBooks.forEach { localLibraryItem ->
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata()
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(mediaMetadata.description.mediaId ?: "")
|
||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
|
||||
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
localPodcasts.forEach { localLibraryItem ->
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata()
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
result.sendResult(localBrowseItems)
|
||||
|
||||
} else { // Load categories
|
||||
|
||||
mediaManager.loadAndroidAutoItems() { libraryCategories ->
|
||||
mediaManager.loadAndroidAutoItems { libraryCategories ->
|
||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
||||
|
||||
val children = browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag")
|
||||
|
||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
||||
if (flag == MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == item.description.mediaId }
|
||||
val description = mediaManager.getMediaDescriptionFromMediaMetadata(item, progress)
|
||||
MediaBrowserCompat.MediaItem(description, flag)
|
||||
} else {
|
||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
||||
}
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
|
|||
import android.content.Context
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
@ -203,8 +204,13 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||
}
|
||||
|
||||
fun handleShake() {
|
||||
Log.d(tag, "HANDLE SHAKE HERE")
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||
return
|
||||
}
|
||||
checkShouldExtendSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseSleepTime(time: Long) {
|
||||
|
|
|
@ -16,6 +16,7 @@ import com.getcapacitor.JSObject
|
|||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
|
@ -43,11 +44,13 @@ class ApiHandler(var ctx:Context) {
|
|||
makeRequest(request, httpClient, cb)
|
||||
}
|
||||
|
||||
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
@ -211,7 +214,7 @@ class ApiHandler(var ctx:Context) {
|
|||
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
|
||||
|
||||
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
||||
postRequest(endpoint, payload) {
|
||||
postRequest(endpoint, payload, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
|
||||
cb(null)
|
||||
|
@ -227,7 +230,7 @@ class ApiHandler(var ctx:Context) {
|
|||
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
||||
|
||||
postRequest("/api/session/$sessionId/sync", payload) {
|
||||
postRequest("/api/session/$sessionId/sync", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false)
|
||||
} else {
|
||||
|
@ -239,7 +242,7 @@ class ApiHandler(var ctx:Context) {
|
|||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||
|
||||
postRequest("/api/session/local", payload) {
|
||||
postRequest("/api/session/local", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false)
|
||||
} else {
|
||||
|
@ -265,7 +268,7 @@ class ApiHandler(var ctx:Context) {
|
|||
if (localMediaProgress.isNotEmpty()) {
|
||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
||||
postRequest("/api/me/sync-local-progress", payload) {
|
||||
postRequest("/api/me/sync-local-progress", payload, null) {
|
||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
||||
|
||||
if (it.toString() == "{}") {
|
||||
|
@ -343,4 +346,25 @@ class ApiHandler(var ctx:Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||
postRequest("/api/authorize", JSObject(), config) {
|
||||
val error = it.getString("error")
|
||||
if (!error.isNullOrEmpty()) {
|
||||
Log.d(tag, "authorize: Authorize ${config.address} Failed: $error")
|
||||
cb(null)
|
||||
} else {
|
||||
val mediaProgressList:MutableList<MediaProgress> = mutableListOf()
|
||||
val user = it.getJSObject("user")
|
||||
val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray()
|
||||
for (i in 0 until mediaProgress.length()) {
|
||||
val mediaProg = jacksonMapper.readValue<MediaProgress>(mediaProgress.getJSONObject(i).toString())
|
||||
mediaProgressList.add(mediaProg)
|
||||
}
|
||||
Log.d(tag, "authorize: Authorize ${config.address} Successful")
|
||||
cb(mediaProgressList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 100 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 101 KiB |
|
@ -1,34 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
|
@ -1,170 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
Before Width: | Height: | Size: 28 KiB |
|
@ -1,4 +1,23 @@
|
|||
<resources>
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||
<item name="android:background">@drawable/app_widget_background</item>
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:background">@drawable/screen</item>
|
||||
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||
</style>
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:id">@android:id/background</item>
|
||||
|
|
|
@ -32,7 +32,8 @@ export default {
|
|||
onMediaPlayerChangedListener: null,
|
||||
sleepInterval: null,
|
||||
currentEndOfChapterTime: 0,
|
||||
serverLibraryItemId: null
|
||||
serverLibraryItemId: null,
|
||||
serverEpisodeId: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -173,15 +174,15 @@ export default {
|
|||
this.$toast.error(`Cannot cast locally downloaded media`)
|
||||
} else {
|
||||
// Change to server library item
|
||||
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
|
||||
this.playServerLibraryItemAndCast(this.serverLibraryItemId, this.serverEpisodeId)
|
||||
}
|
||||
},
|
||||
playServerLibraryItemAndCast(libraryItemId) {
|
||||
playServerLibraryItemAndCast(libraryItemId, episodeId) {
|
||||
var playbackRate = 1
|
||||
if (this.$refs.audioPlayer) {
|
||||
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
||||
}
|
||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
|
||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: false, playbackRate })
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
const errorMsg = data.error || 'Failed to play'
|
||||
|
@ -203,6 +204,7 @@ export default {
|
|||
// 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
|
||||
var serverEpisodeId = payload.serverEpisodeId || null
|
||||
|
||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||
const { value } = await Dialog.confirm({
|
||||
|
@ -215,6 +217,7 @@ export default {
|
|||
}
|
||||
|
||||
this.serverLibraryItemId = null
|
||||
this.serverEpisodeId = null
|
||||
|
||||
var playbackRate = 1
|
||||
if (this.$refs.audioPlayer) {
|
||||
|
@ -234,6 +237,11 @@ export default {
|
|||
} else {
|
||||
this.serverLibraryItemId = serverLibraryItemId
|
||||
}
|
||||
if (episodeId && !episodeId.startsWith('local')) {
|
||||
this.serverEpisodeId = episodeId
|
||||
} else {
|
||||
this.serverEpisodeId = serverEpisodeId
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
|
@ -99,6 +99,11 @@ export default {
|
|||
text: 'Account',
|
||||
to: '/account'
|
||||
})
|
||||
items.push({
|
||||
icon: 'equalizer',
|
||||
text: 'User Stats',
|
||||
to: '/stats'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.$platform !== 'ios') {
|
||||
|
|
|
@ -250,7 +250,7 @@ export default {
|
|||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
if (this.recentEpisode) return this.episodeProgress || null
|
||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
<div class="relative flex items-center px-2">
|
||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||
|
|
|
@ -86,6 +86,11 @@ export default {
|
|||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Language',
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Progress',
|
||||
value: 'progress',
|
||||
|
@ -165,6 +170,9 @@ export default {
|
|||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
languages() {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
||||
},
|
||||
|
|
219
components/stats/DailyListeningChart.vue
Normal file
|
@ -0,0 +1,219 @@
|
|||
<template>
|
||||
<div class="w-96 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
|
||||
<div class="relative w-96 h-72">
|
||||
<div class="absolute top-0 left-0">
|
||||
<template v-for="lbl in yAxisLabels">
|
||||
<div :key="lbl" :style="{ height: lineSpacing + 'px' }" class="flex items-center justify-end">
|
||||
<p class="text-xs font-semibold">{{ lbl }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-for="n in 7">
|
||||
<div :key="n" class="absolute pointer-events-none left-0 h-px bg-white bg-opacity-10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" />
|
||||
|
||||
<div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }">
|
||||
<div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-for="(line, index) in pointLines">
|
||||
<div :key="`line-${index}`" class="absolute h-0.5 bg-yellow-400 origin-bottom-left pointer-events-none" :style="{ width: line.width + 'px', left: line.x + 'px', bottom: line.y + 'px', transform: `rotate(${line.angle}deg)` }" />
|
||||
</template>
|
||||
|
||||
<div class="absolute -bottom-2 left-0 flex ml-6">
|
||||
<template v-for="dayObj in last7Days">
|
||||
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
|
||||
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between pt-12">
|
||||
<div>
|
||||
<p class="text-sm text-center">Week Listening</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
|
||||
<p class="text-sm text-center">minutes</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">Daily Average</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
|
||||
<p class="text-sm text-center">minutes</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">Best Day</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
|
||||
<p class="text-sm text-center">minutes</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">Days</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
|
||||
<p class="text-sm text-center">in a row</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
listeningStats: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// test: [111, 120, 4, 156, 273, 76, 12],
|
||||
chartHeight: 288,
|
||||
chartWidth: 384,
|
||||
chartContentWidth: 360,
|
||||
chartContentHeight: 268
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
yAxisLabels() {
|
||||
var lbls = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
lbls.push(i * this.yAxisFactor)
|
||||
}
|
||||
return lbls
|
||||
},
|
||||
chartContentMarginLeft() {
|
||||
return this.chartWidth - this.chartContentWidth
|
||||
},
|
||||
chartContentMarginBottom() {
|
||||
return this.chartHeight - this.chartContentHeight
|
||||
},
|
||||
lineSpacing() {
|
||||
return this.chartHeight / 7
|
||||
},
|
||||
daySpacing() {
|
||||
return this.chartContentWidth / 7
|
||||
},
|
||||
linePositions() {
|
||||
var poses = []
|
||||
for (let i = 7; i > 0; i--) {
|
||||
poses.push(i * this.lineSpacing)
|
||||
}
|
||||
poses.push(0)
|
||||
return poses
|
||||
},
|
||||
last7Days() {
|
||||
var days = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
var _date = this.$addDaysToToday(i * -1)
|
||||
days.push({
|
||||
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
|
||||
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
})
|
||||
}
|
||||
return days
|
||||
},
|
||||
last7DaysOfListening() {
|
||||
var listeningDays = {}
|
||||
var _index = 0
|
||||
this.last7Days.forEach((dayObj) => {
|
||||
listeningDays[_index++] = {
|
||||
dayOfWeek: dayObj.dayOfWeek,
|
||||
// minutesListening: this.test[_index - 1]
|
||||
minutesListening: this.getMinutesListeningForDate(dayObj.date)
|
||||
}
|
||||
})
|
||||
return listeningDays
|
||||
},
|
||||
mostListenedDay() {
|
||||
var sorted = Object.values(this.last7DaysOfListening)
|
||||
.map((dl) => ({ ...dl }))
|
||||
.sort((a, b) => b.minutesListening - a.minutesListening)
|
||||
return sorted[0].minutesListening
|
||||
},
|
||||
yAxisFactor() {
|
||||
var factor = Math.ceil(this.mostListenedDay / 5)
|
||||
|
||||
if (factor > 25) {
|
||||
// Use nearest multiple of 5
|
||||
return Math.ceil(factor / 5) * 5
|
||||
}
|
||||
|
||||
return Math.max(1, factor)
|
||||
},
|
||||
points() {
|
||||
var data = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
var listeningObj = this.last7DaysOfListening[String(i)]
|
||||
var minutesListening = listeningObj.minutesListening || 0
|
||||
var yPercent = minutesListening / (this.yAxisFactor * 7)
|
||||
data.push({
|
||||
x: 4 + this.chartContentMarginLeft + (this.daySpacing + this.daySpacing / 14) * i,
|
||||
y: this.chartContentMarginBottom + this.chartHeight * yPercent - 2
|
||||
})
|
||||
}
|
||||
return data
|
||||
},
|
||||
pointLines() {
|
||||
var lines = []
|
||||
for (let i = 1; i < 7; i++) {
|
||||
var lastPoint = this.points[i - 1]
|
||||
var nextPoint = this.points[i]
|
||||
|
||||
var x1 = lastPoint.x
|
||||
var x2 = nextPoint.x
|
||||
var y1 = lastPoint.y
|
||||
var y2 = nextPoint.y
|
||||
|
||||
lines.push({
|
||||
x: x1 + 4,
|
||||
y: y1 + 2,
|
||||
angle: this.getAngleBetweenPoints(x1, y1, x2, y2),
|
||||
width: Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) - 2
|
||||
})
|
||||
}
|
||||
return lines
|
||||
},
|
||||
totalMinutesListeningThisWeek() {
|
||||
var _total = 0
|
||||
Object.values(this.last7DaysOfListening).forEach((listeningObj) => (_total += listeningObj.minutesListening))
|
||||
return _total
|
||||
},
|
||||
averageMinutesPerDay() {
|
||||
return Math.round(this.totalMinutesListeningThisWeek / 7)
|
||||
},
|
||||
daysInARow() {
|
||||
var count = 0
|
||||
while (true) {
|
||||
var _date = this.$addDaysToToday(count * -1)
|
||||
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
|
||||
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
|
||||
if (count > 9999) {
|
||||
console.error('Overflow protection')
|
||||
return 0
|
||||
}
|
||||
}
|
||||
},
|
||||
listeningStatsDays() {
|
||||
return this.listeningStats ? this.listeningStats.days || [] : []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAngleBetweenPoints(cx, cy, ex, ey) {
|
||||
var dy = ey - cy
|
||||
var dx = ex - cx
|
||||
var theta = Math.atan2(dy, dx)
|
||||
theta *= 180 / Math.PI // convert to degrees
|
||||
return theta * -1
|
||||
},
|
||||
getMinutesListeningForDate(date) {
|
||||
if (!this.listeningStats || !this.listeningStats.days) return 0
|
||||
return Math.round((this.listeningStats.days[date] || 0) / 60)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
|
@ -1,12 +1,14 @@
|
|||
<template>
|
||||
<div class="w-full bg-primary bg-opacity-40">
|
||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||
<p>Collection List</p>
|
||||
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
|
||||
<p class="font-mono text-sm">{{ books.length }}</p>
|
||||
<p class="pr-4">Collection List</p>
|
||||
|
||||
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
|
||||
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
<template v-for="book in booksCopy">
|
||||
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
|
||||
|
@ -39,12 +41,12 @@ export default {
|
|||
totalDuration() {
|
||||
var _total = 0
|
||||
this.books.forEach((book) => {
|
||||
_total += book.duration
|
||||
_total += book.media.duration
|
||||
})
|
||||
return _total
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$elapsedPretty(this.totalDuration)
|
||||
return this.$elapsedPrettyExtended(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -211,7 +211,9 @@ export default {
|
|||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: this.localEpisode.id
|
||||
episodeId: this.localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.episode.id
|
||||
})
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
|
|
|
@ -46,7 +46,7 @@ export default {
|
|||
episodesCopy: [],
|
||||
showFiltersModal: false,
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: false,
|
||||
sortDesc: true,
|
||||
filterKey: 'incomplete',
|
||||
episodeSortItems: [
|
||||
{
|
||||
|
|
|
@ -587,12 +587,12 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.55;
|
||||
MARKETING_VERSION = 0.9.56;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -611,12 +611,12 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.55;
|
||||
MARKETING_VERSION = 0.9.56;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
|
|
@ -155,24 +155,26 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||
@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 ]) }
|
||||
let timeSeconds = time / 1000
|
||||
|
||||
NSLog("chapter time: \(call.getBool("isChapterTime", false))")
|
||||
|
||||
if call.getBool("isChapterTime", false) {
|
||||
let timeToPause = time / 1000 - Int(PlayerHandler.getCurrentTime() ?? 0)
|
||||
let timeToPause = timeSeconds - Int(PlayerHandler.getCurrentTime() ?? 0)
|
||||
if timeToPause < 0 { return call.resolve([ "success": false ]) }
|
||||
|
||||
NSLog("oof \(timeToPause)")
|
||||
|
||||
PlayerHandler.sleepTimerChapterStopTime = timeSeconds
|
||||
PlayerHandler.remainingSleepTime = timeToPause
|
||||
return call.resolve([ "success": true ])
|
||||
}
|
||||
|
||||
PlayerHandler.remainingSleepTime = time / 1000
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
PlayerHandler.remainingSleepTime = timeSeconds
|
||||
call.resolve([ "success": true ])
|
||||
}
|
||||
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
||||
PlayerHandler.remainingSleepTime = nil
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
call.resolve()
|
||||
}
|
||||
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
||||
|
|
|
@ -13,6 +13,7 @@ class PlayerHandler {
|
|||
private static var timer: Timer?
|
||||
private static var lastSyncTime: Double = 0.0
|
||||
|
||||
public static var sleepTimerChapterStopTime: Int? = nil
|
||||
private static var _remainingSleepTime: Int? = nil
|
||||
public static var remainingSleepTime: Int? {
|
||||
get {
|
||||
|
@ -151,18 +152,28 @@ class PlayerHandler {
|
|||
private static func tick() {
|
||||
if !paused {
|
||||
listeningTimePassedSinceLastSync += 1
|
||||
|
||||
if remainingSleepTime != nil {
|
||||
if sleepTimerChapterStopTime != nil {
|
||||
let timeUntilChapterEnd = Double(sleepTimerChapterStopTime ?? 0) - (getCurrentTime() ?? 0)
|
||||
if timeUntilChapterEnd <= 0 {
|
||||
paused = true
|
||||
remainingSleepTime = nil
|
||||
} else {
|
||||
remainingSleepTime = Int(timeUntilChapterEnd.rounded())
|
||||
}
|
||||
} else {
|
||||
if remainingSleepTime! <= 0 {
|
||||
paused = true
|
||||
}
|
||||
remainingSleepTime! -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if listeningTimePassedSinceLastSync >= 5 {
|
||||
syncProgress()
|
||||
}
|
||||
|
||||
if remainingSleepTime != nil {
|
||||
if remainingSleepTime! == 0 {
|
||||
paused = true
|
||||
}
|
||||
remainingSleepTime! -= 1
|
||||
}
|
||||
}
|
||||
|
||||
public static func syncProgress() {
|
||||
|
|
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.55-beta",
|
||||
"version": "0.9.56-beta",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.55-beta",
|
||||
"version": "0.9.56-beta",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^3.4.3",
|
||||
"@capacitor/app": "^1.1.1",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.55-beta",
|
||||
"version": "0.9.56-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
|
|
|
@ -56,6 +56,9 @@ export default {
|
|||
},
|
||||
altViewEnabled() {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
},
|
||||
localMediaProgress() {
|
||||
return this.$store.state.globals.localMediaProgress
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -69,6 +72,7 @@ export default {
|
|||
var podcasts = []
|
||||
localMedia.forEach((item) => {
|
||||
if (item.mediaType == 'book') {
|
||||
item.progress = this.localMediaProgress.find((lmp) => lmp.id === item.id)
|
||||
books.push(item)
|
||||
} else if (item.mediaType == 'podcast') {
|
||||
podcasts.push(item)
|
||||
|
@ -80,7 +84,11 @@ export default {
|
|||
id: 'local-books',
|
||||
label: 'Local Books',
|
||||
type: 'book',
|
||||
entities: books.slice(0, 10)
|
||||
entities: books.sort((a, b) => {
|
||||
if (a.progress && a.progress.isFinished) return 1
|
||||
else if (b.progress && b.progress.isFinished) return -1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
}
|
||||
if (podcasts.length) {
|
||||
|
@ -88,7 +96,7 @@ export default {
|
|||
id: 'local-podcasts',
|
||||
label: 'Local Podcasts',
|
||||
type: 'podcast',
|
||||
entities: podcasts.slice(0, 10)
|
||||
entities: podcasts
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -9,20 +9,21 @@
|
|||
</div>
|
||||
<div class="title-container flex-grow pl-2">
|
||||
<div class="flex relative pr-6">
|
||||
<h1 class="text-base">{{ title }}</h1>
|
||||
<h1 class="text-base font-semibold">{{ title }}</h1>
|
||||
|
||||
<button class="absolute top-0 right-0 h-full px-1 outline-none" @click="moreButtonPress">
|
||||
<span class="material-icons text-xl">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-gray-100 text-sm py-0.5">{{ subtitle }}</p>
|
||||
<p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
|
||||
<template v-for="(series, index) in seriesList"
|
||||
><nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span></template
|
||||
>
|
||||
</p>
|
||||
<p v-if="podcastAuthor" class="text-sm text-gray-400 py-0.5">By {{ podcastAuthor }}</p>
|
||||
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-400 py-0.5">
|
||||
<p v-if="podcastAuthor" class="text-sm text-gray-300 py-0.5">By {{ podcastAuthor }}</p>
|
||||
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5">
|
||||
By
|
||||
<template v-for="(author, index) in bookAuthors"
|
||||
><nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`">{{ author.name }}</nuxt-link
|
||||
|
@ -84,7 +85,7 @@
|
|||
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4">
|
||||
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
|
||||
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
|
||||
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play' : 'Stream' }}</span>
|
||||
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isPodcast ? 'Next Episode' : hasLocal ? 'Play' : 'Stream' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
|
||||
<span class="material-icons">auto_stories</span>
|
||||
|
@ -218,6 +219,9 @@ export default {
|
|||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
subtitle() {
|
||||
return this.mediaMetadata.subtitle
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || ''
|
||||
|
@ -301,13 +305,13 @@ export default {
|
|||
return this.libraryItem.isIncomplete
|
||||
},
|
||||
showPlay() {
|
||||
return !this.isMissing && !this.isIncomplete && this.numTracks
|
||||
return !this.isMissing && !this.isIncomplete && (this.numTracks || this.episodes.length)
|
||||
},
|
||||
showRead() {
|
||||
return this.ebookFile && this.ebookFormat !== 'pdf'
|
||||
},
|
||||
showDownload() {
|
||||
// if (this.isIos) return false
|
||||
if (this.isPodcast) return false
|
||||
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
|
||||
},
|
||||
ebookFile() {
|
||||
|
@ -364,14 +368,58 @@ export default {
|
|||
this.$store.commit('openReader', this.libraryItem)
|
||||
},
|
||||
playClick() {
|
||||
// Todo: Allow playing local or streaming
|
||||
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
|
||||
var episodeId = null
|
||||
|
||||
if (this.isPodcast) {
|
||||
this.episodes.sort((a, b) => {
|
||||
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
|
||||
var episode = this.episodes.find((ep) => {
|
||||
var podcastProgress = null
|
||||
if (!this.isLocal) {
|
||||
podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
|
||||
} else {
|
||||
podcastProgress = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, ep.id)
|
||||
}
|
||||
return !podcastProgress || !podcastProgress.isFinished
|
||||
})
|
||||
|
||||
if (!episode) episode = this.episodes[0]
|
||||
|
||||
episodeId = episode.id
|
||||
|
||||
var localEpisode = null
|
||||
if (this.hasLocal && !this.isLocal) {
|
||||
localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId == episodeId)
|
||||
} else if (this.isLocal) {
|
||||
localEpisode = episode
|
||||
}
|
||||
var serverEpisodeId = !this.isLocal ? episodeId : localEpisode ? localEpisode.serverEpisodeId : null
|
||||
|
||||
if (serverEpisodeId && this.serverLibraryItemId && this.isCasting) {
|
||||
// If casting and connected to server for local library item then send server library item id
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.serverLibraryItemId, episodeId: serverEpisodeId })
|
||||
return
|
||||
}
|
||||
if (localEpisode) {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id, episodeId: localEpisode.id, serverLibraryItemId: this.serverLibraryItemId, serverEpisodeId })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Audiobook
|
||||
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) {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id, serverLibraryItemId: 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, episodeId })
|
||||
},
|
||||
async clearProgressClick() {
|
||||
const { value } = await Dialog.confirm({
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
<template>
|
||||
<div class="w-full h-full p-8">
|
||||
<p class="uppercase text-xs font-semibold text-gray-300 mb-2">Display Settings</p>
|
||||
<div class="flex items-center py-3" @click="toggleEnableAltView">
|
||||
<div class="w-10 flex justify-center">
|
||||
<ui-toggle-switch v-model="settings.enableAltView" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">Alternative Bookshelf View</p>
|
||||
<p class="pl-4">Alternative bookshelf view</p>
|
||||
</div>
|
||||
<div v-if="$platform !== 'ios'" class="flex items-center py-3" @click="toggleDisableAutoRewind">
|
||||
|
||||
<p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Playback Settings</p>
|
||||
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind">
|
||||
<div class="w-10 flex justify-center">
|
||||
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">Disable Auto Rewind</p>
|
||||
<p class="pl-4">Disable auto rewind</p>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click="toggleJumpBackwards">
|
||||
<div class="w-10 flex justify-center">
|
||||
|
@ -24,10 +27,21 @@
|
|||
</div>
|
||||
<p class="pl-4">Jump forwards time</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!isiOS" class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Sleep Timer Settings</p>
|
||||
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableShakeToResetSleepTimer">
|
||||
<div class="w-10 flex justify-center">
|
||||
<ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">Disable shake to reset</p>
|
||||
<span class="material-icons-outlined ml-2" @click.stop="showInfo('disableShakeToResetSleepTimer')">info</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
|
@ -36,11 +50,21 @@ export default {
|
|||
disableAutoRewind: false,
|
||||
enableAltView: false,
|
||||
jumpForwardTime: 10,
|
||||
jumpBackwardsTime: 10
|
||||
jumpBackwardsTime: 10,
|
||||
disableShakeToResetSleepTimer: false
|
||||
},
|
||||
settingInfo: {
|
||||
disableShakeToResetSleepTimer: {
|
||||
name: 'Disable shake to reset sleep timer',
|
||||
message: 'The sleep timer will start fading out when 30s is remaining. Shaking your device will reset the timer if it is within 30s OR has finished less than 2 mintues ago. Enable this setting to disable that feature.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isiOS() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
jumpForwardItems() {
|
||||
return this.$store.state.globals.jumpForwardItems || []
|
||||
},
|
||||
|
@ -63,6 +87,18 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
showInfo(setting) {
|
||||
if (this.settingInfo[setting]) {
|
||||
Dialog.alert({
|
||||
title: this.settingInfo[setting].name,
|
||||
message: this.settingInfo[setting].message
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleDisableShakeToResetSleepTimer() {
|
||||
this.settings.disableShakeToResetSleepTimer = !this.settings.disableShakeToResetSleepTimer
|
||||
this.saveSettings()
|
||||
},
|
||||
toggleDisableAutoRewind() {
|
||||
this.settings.disableAutoRewind = !this.settings.disableAutoRewind
|
||||
this.saveSettings()
|
||||
|
@ -99,6 +135,7 @@ export default {
|
|||
this.settings.enableAltView = !!deviceSettings.enableAltView
|
||||
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
|
||||
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
|
||||
this.settings.disableShakeToResetSleepTimer = !!deviceSettings.disableShakeToResetSleepTimer
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
114
pages/stats.vue
Normal file
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<div class="w-full h-full px-0 py-4 overflow-y-auto">
|
||||
<h1 class="text-xl px-4">
|
||||
Stats for <b>{{ username }}</b>
|
||||
</h1>
|
||||
|
||||
<div class="flex text-center justify-center">
|
||||
<div class="flex p-2">
|
||||
<div class="px-3">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Items Finished</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex p-2">
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex p-2">
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||
<stats-daily-listening-chart :listening-stats="listeningStats" class="lg:scale-100 transform scale-90 px-0" />
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<div class="flex mb-4 items-center">
|
||||
<h1 class="text-2xl font-book">Recent Sessions</h1>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||
<div :key="item.id" class="w-full py-0.5">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}. </p>
|
||||
<div class="w-56">
|
||||
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
|
||||
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="w-18 text-right">
|
||||
<p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
listeningStats: null,
|
||||
windowWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentLibraryId(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : ''
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
userMediaProgress() {
|
||||
return this.user ? this.user.mediaProgress : []
|
||||
},
|
||||
userItemsFinished() {
|
||||
return this.userMediaProgress.filter((lip) => !!lip.isFinished)
|
||||
},
|
||||
mostRecentListeningSessions() {
|
||||
if (!this.listeningStats) return []
|
||||
return this.listeningStats.recentSessions || []
|
||||
},
|
||||
totalMinutesListening() {
|
||||
if (!this.listeningStats) return 0
|
||||
return Math.round(this.listeningStats.totalTime / 60)
|
||||
},
|
||||
totalDaysListened() {
|
||||
if (!this.listeningStats) return 0
|
||||
return Object.values(this.listeningStats.days).length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
console.log('Loaded user listening data', this.listeningStats)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -2,7 +2,7 @@ import Vue from 'vue'
|
|||
import { App } from '@capacitor/app'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import { formatDistance, format } from 'date-fns'
|
||||
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
Vue.prototype.$eventBus = new Vue()
|
||||
|
@ -24,7 +24,20 @@ Vue.prototype.$formatDate = (unixms, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
|||
if (!unixms) return ''
|
||||
return format(unixms, fnsFormat)
|
||||
}
|
||||
|
||||
Vue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||
var date = addDays(new Date(), daysToAdd)
|
||||
if (!date || !isDate(date)) return null
|
||||
return date
|
||||
}
|
||||
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
|
||||
var date = addDays(jsdate, daysToAdd)
|
||||
if (!date || !isDate(date)) return null
|
||||
return date
|
||||
}
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes === null) return 'Invalid Bytes'
|
||||
if (bytes === 0) {
|
||||
|
@ -53,6 +66,29 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
|||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
seconds = Math.round(seconds)
|
||||
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
seconds -= minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
var days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
var strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
if (minutes) strs.push(`${minutes}m`)
|
||||
if (seconds) strs.push(`${seconds}s`)
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
var _seconds = seconds
|
||||
var _minutes = Math.floor(seconds / 60)
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import Vue from "vue";
|
||||
import Toast from "vue-toastification";
|
||||
import "vue-toastification/dist/index.css";
|
||||
import Vue from "vue"
|
||||
import Toast from "vue-toastification"
|
||||
import "vue-toastification/dist/index.css"
|
||||
|
||||
const options = {
|
||||
hideProgressBar: true,
|
||||
position: 'bottom-center'
|
||||
};
|
||||
}
|
||||
|
||||
Vue.use(Toast, options);
|
||||
Vue.use(Toast, options)
|