Compare commits

...
Author SHA1 Message Date
advplyr bad8d10a18 Fix:Android 9 and below scan folder for m4b #220 2022-06-05 16:46:38 -05:00
advplyr a4412da3ed Version bump 0.9.49-beta 2022-06-05 16:38:45 -05:00
advplyr 870774b408 Fix:Remove last saved library id when changing servers so home page shelves load on init 2022-06-05 16:32:28 -05:00
advplyr d7dcaa22a6 Fix:Logout button on account page #222 2022-06-05 16:01:05 -05:00
advplyr cc0b2943dd Fix:Hide play button for local podcast 2022-06-05 15:56:23 -05:00
advplyr 50ee0b2265 Fix:Local downloaded books crash when server progress check fails, fix check pause timeout length #219 2022-06-05 15:52:54 -05:00
advplyr 910b3a2a17 Version bump 0.9.48-beta 2022-06-05 09:55:22 -05:00
advplyr 916da91ccb Fix:Android auto check if server connection available and use first available server config, add ping api check with 3 second timeout #212 #167 2022-06-05 09:51:37 -05:00
advplyr d70a99254a Android version code bump 2022-06-04 17:20:14 -05:00
advplyr dcf5bb61a2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-06-04 17:12:14 -05:00
advplyr aa65bb10c1 Update:Android auto reset saved data if server config id changes 2022-06-04 17:12:10 -05:00
advplyr 158448f8a2 iOS version bump 0.9.47 2022-06-04 16:37:26 -05:00
advplyr f4be9b3e26 Update:Pass device info with play request 2022-06-04 16:36:49 -05:00
advplyr 15d68ca285 Version bump 0.9.47-beta 2022-06-04 15:11:49 -05:00
advplyr ad12e6a19d Add:Author, narrator and series links on item page #186 2022-06-04 10:19:31 -05:00
advplyr 1f1b2fe85a Add:Filter for podcast episodes 2022-06-03 19:46:43 -05:00
advplyr 480df58ce4 Fix:Check with server after pause of 1 minute or longer for updated media progress & show toast on client if progress sync is failing 2022-06-03 18:58:07 -05:00
advplyr 2decf532b2 Add:Not Finished progress filter 2022-06-02 18:20:52 -05:00
advplyr 3ba87419ae Update:Hide size on item landing page if not set 2022-06-02 18:14:29 -05:00
advplyr 1c78af37fa Update:Item page redesign with more details like narrator and chapters, decreasing cover size 2022-06-02 17:57:37 -05:00
advplyr 2b5373aedd Update:Rename Hard Delete 2022-06-02 17:02:30 -05:00
advplyr 99bf960b8a Add:Filter and sort for podcast episodes table, Update:Sync local media progress when media progress is updated on the server 2022-06-01 19:38:26 -05:00
advplyr c4aca22c28 Fix:Android auto reset server items if no longer connected to server #201 2022-05-29 18:43:53 -05:00
advplyr 58bd0e0cee Add:Click and drag player progress track #110 2022-05-29 18:13:25 -05:00
advplyr c1c56f8f52 Fix play request payload 2022-05-27 20:48:13 -05:00
advplyr f930ba1941 Update:Send android device info when opening playback sessions 2022-05-26 19:10:29 -05:00
advplyr 251116a5ce Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-05-24 09:08:28 -05:00
advplyr 06739c0401 Fix:Base64 encoding include URL_SAFE flag to prevent having / in local library item ids #192 2022-05-24 09:08:23 -05:00
advplyr 92f069d1e6 iOS version bump 0.9.46 2022-05-22 17:51:35 -05:00
advplyr 62c5042ecc Fix:Cover image stretch to fill 2022-05-22 17:49:10 -05:00
advplyr 604b086c0b Android version bump 0.9.46-beta 2022-05-22 17:04:19 -05:00
advplyr 0e0b356f6b Fix:Reset volume when playing from notification after sleep timer decreases volume #182 2022-05-22 16:24:38 -05:00
advplyr 573768e2b2 Fix:Select podcast folder when none is set yet 2022-05-22 15:56:32 -05:00
advplyr 236fd09c94 Fix:Hide download button if user does not have download permission #189 2022-05-22 15:49:42 -05:00
advplyr cb6cb5f637 Fix:M4B downloads on Android 9 & 10 not detected as audio files (mimeType is showing as application/octet-stream) #154 2022-05-22 15:31:25 -05:00
advplyr eb916a3ab0 Add:Duration sort 2022-05-22 14:47:07 -05:00
advplyr 61b8ca1510 Add:Persist scroll position for bookshelves #196, Fix:Hide clear filter button for series and collections 2022-05-22 14:19:13 -05:00
advplyr 5d2da97dc5 Update:Android auto to show libraries and browseable podcasts, Fix:Download podcast set episodes and cover art correctly 2022-05-22 13:40:01 -05:00
63 changed files with 1331 additions and 302 deletions
+2 -2
View File
@@ -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 74 versionCode 80
versionName "0.9.45-beta" versionName "0.9.49-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.
@@ -42,6 +42,15 @@ data class LibraryItem(
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
} }
@JsonIgnore
fun checkHasTracks():Boolean {
return if (mediaType == "podcast") {
((media as Podcast).numEpisodes ?: 0) > 0
} else {
((media as Book).numTracks ?: 0) > 0
}
}
@JsonIgnore @JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat { fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply { return MediaMetadataCompat.Builder().apply {
@@ -74,6 +83,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
open fun removeAudioTrack(localFileId:String) { } open fun removeAudioTrack(localFileId:String) { }
@JsonIgnore @JsonIgnore
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) } open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -82,7 +92,8 @@ class Podcast(
coverPath:String?, coverPath:String?,
var tags:MutableList<String>, var tags:MutableList<String>,
var episodes:MutableList<PodcastEpisode>?, var episodes:MutableList<PodcastEpisode>?,
var autoDownloadEpisodes:Boolean var autoDownloadEpisodes:Boolean,
var numEpisodes:Int?
) : MediaType(metadata, coverPath) { ) : MediaType(metadata, coverPath) {
@JsonIgnore @JsonIgnore
override fun getAudioTracks():List<AudioTrack> { override fun getAudioTracks():List<AudioTrack> {
@@ -99,7 +110,7 @@ class Podcast(
// Add new episodes // Add new episodes
audioTracks.forEach { at -> audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null) val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
} }
} }
@@ -147,7 +158,7 @@ class Podcast(
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes // Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Podcast { override fun getLocalCopy(): Podcast {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes) return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
} }
} }
@@ -160,7 +171,8 @@ class Book(
var chapters:List<BookChapter>?, var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?, var tracks:MutableList<AudioTrack>?,
var size:Long?, var size:Long?,
var duration:Double? var duration:Double?,
var numTracks:Int?
) : MediaType(metadata, coverPath) { ) : MediaType(metadata, coverPath) {
@JsonIgnore @JsonIgnore
override fun getAudioTracks():List<AudioTrack> { override fun getAudioTracks():List<AudioTrack> {
@@ -209,7 +221,7 @@ class Book(
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Book { override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null) return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
} }
} }
@@ -281,7 +293,29 @@ data class PodcastEpisode(
var duration:Double?, var duration:Double?,
var size:Long?, var size:Long?,
var serverEpisodeId:String? // For local podcasts to match with server podcasts var serverEpisodeId:String? // For local podcasts to match with server podcasts
) ) {
@JsonIgnore
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat {
var coverUri:Uri = Uri.EMPTY
val podcast = if(libraryItem is LocalLibraryItem) {
coverUri = libraryItem.getCoverUri()
libraryItem.media as Podcast
} else {
coverUri = (libraryItem as LibraryItem).getCoverUri()
(libraryItem as LibraryItem).media as Podcast
}
return MediaMetadataCompat.Builder().apply {
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())
}.build()
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryFile( data class LibraryFile(
@@ -312,7 +346,16 @@ data class Library(
var folders:MutableList<Folder>, var folders:MutableList<Folder>,
var icon:String, var icon:String,
var mediaType:String var mediaType:String
) ) {
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
}.build()
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class Folder( data class Folder(
@@ -371,3 +414,9 @@ data class MediaProgress(
var startedAt:Long, var startedAt:Long,
var finishedAt:Long? var finishedAt:Long?
) )
// Helper class
data class LibraryItemWithEpisode(
var libraryItemWrapper:LibraryItemWrapper,
var episode:PodcastEpisode
)
@@ -42,6 +42,20 @@ class DbManager {
return Paper.book("localLibraryItems").read(localLibraryItemId) return Paper.book("localLibraryItems").read(localLibraryItemId)
} }
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
var podcastEpisode:PodcastEpisode? = null
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
podcastEpisode != null
}
return if (localLibraryItem != null) {
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
} else {
null
}
}
fun removeLocalLibraryItem(localLibraryItemId:String) { fun removeLocalLibraryItem(localLibraryItemId:String) {
Paper.book("localLibraryItems").delete(localLibraryItemId) Paper.book("localLibraryItems").delete(localLibraryItemId)
} }
@@ -42,6 +42,8 @@ data class LocalFile(
) { ) {
@JsonIgnore @JsonIgnore
fun isAudioFile():Boolean { fun isAudioFile():Boolean {
if (mimeType == "application/octet-stream") return true
if (mimeType == "video/mp4") return true
return mimeType?.startsWith("audio") == true return mimeType?.startsWith("audio") == true
} }
} }
@@ -64,3 +66,20 @@ data class LocalFolder(
JsonSubTypes.Type(LocalLibraryItem::class) JsonSubTypes.Type(LocalLibraryItem::class)
) )
open class LibraryItemWrapper() open class LibraryItemWrapper()
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo(
var manufacturer:String,
var model:String,
var brand:String,
var sdkVersion:Int,
var clientVersion: String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PlayItemRequestPayload(
var mediaPlayer:String,
var forceDirectPlay:Boolean,
var forceTranscode:Boolean,
var deviceInfo:DeviceInfo
)
@@ -58,13 +58,13 @@ data class LocalMediaItem(
@JsonIgnore @JsonIgnore
fun getLocalLibraryItem():LocalLibraryItem { fun getLocalLibraryItem():LocalLibraryItem {
var mediaMetadata = getMediaMetadata() val mediaMetadata = getMediaMetadata()
if (mediaType == "book") { if (mediaType == "book") {
var chapters = getAudiobookChapters() val chapters = getAudiobookChapters()
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration()) val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else { } else {
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false) val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
} }
@@ -42,4 +42,15 @@ data class LocalMediaProgress(
isFinished = playbackSession.progress >= 0.99 isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null finishedAt = if (isFinished) lastUpdate else null
} }
@JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime
duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt
startedAt = serverMediaProgress.startedAt
}
} }
@@ -62,6 +62,8 @@ class PlaybackSession(
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore @get:JsonIgnore
val progress get() = currentTime / getTotalDuration() val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@JsonIgnore @JsonIgnore
fun getCurrentTrackIndex():Int { fun getCurrentTrackIndex():Int {
@@ -24,6 +24,11 @@ object DeviceManager {
} }
fun getBase64Id(id:String):String { fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.NO_WRAP) return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
}
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
if (id == null) return null
return deviceData.serverConnectionConfigs.find { it.id == id }
} }
} }
@@ -105,7 +105,7 @@ class FolderScanner(var ctx: Context) {
var coverContentUrl:String? = null var coverContentUrl:String? = null
var coverAbsolutePath:String? = null var coverAbsolutePath:String? = null
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4")) val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
val existingLocalFilesRemoved = existingLocalFiles.filter { elf -> val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
@@ -250,7 +250,8 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId") Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder // Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4")) // m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null var localEpisodeId:String? = null
@@ -288,7 +289,7 @@ class FolderScanner(var ctx: Context) {
val audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track // Create new audio track
val track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1) val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
audioTracks.add(track) audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}") Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
@@ -296,7 +297,7 @@ class FolderScanner(var ctx: Context) {
// Add podcast episodes to library // Add podcast episodes to library
itemPart.episode?.let { podcastEpisode -> itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast val podcast = localLibraryItem.media as Podcast
var newEpisode = podcast.addEpisode(track, podcastEpisode) val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}") Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
} }
@@ -366,7 +367,7 @@ class FolderScanner(var ctx: Context) {
} }
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? { fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl)) val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
if (df == null) { if (df == null) {
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}") Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
@@ -377,7 +378,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false var wasUpdated = false
// Search for files in media item folder // Search for files in media item folder
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4")) val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}") Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach { filesFound.forEach {
@@ -388,10 +389,10 @@ class FolderScanner(var ctx: Context) {
} }
} }
var existingAudioTracks = localLibraryItem.media.getAudioTracks() val existingAudioTracks = localLibraryItem.media.getAudioTracks()
// Remove any files no longer found in library item folder // Remove any files no longer found in library item folder
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id } val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
existingLocalFileIds.forEach { localFileId -> existingLocalFileIds.forEach { localFileId ->
Log.d(tag, "Checking local file id is there $localFileId") Log.d(tag, "Checking local file id is there $localFileId")
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) { if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
@@ -407,12 +408,12 @@ class FolderScanner(var ctx: Context) {
} }
filesFound.forEach { docFile -> filesFound.forEach { docFile ->
var localFileId = DeviceManager.getBase64Id(docFile.id) val localFileId = DeviceManager.getBase64Id(docFile.id)
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId } val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) { if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
if (existingLocalFile == null) { if (existingLocalFile == null) {
localLibraryItem.localFiles.add(localFile) localLibraryItem.localFiles.add(localFile)
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}") Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
@@ -420,22 +421,26 @@ class FolderScanner(var ctx: Context) {
if (localFile.isAudioFile()) { if (localFile.isAudioFile()) {
// TODO: Make asynchronous // TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
var existingTrack = existingAudioTracks.find { audioTrack -> val existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id audioTrack.localFileId == localFile.id
} }
if (existingTrack == null) { if (existingTrack == null) {
// Create new audio track // Create new audio track
var lastTrack = existingAudioTracks.lastOrNull() val lastTrack = existingAudioTracks.lastOrNull()
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0) val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null) val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track) localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true wasUpdated = true
} else { } else {
existingTrack.audioProbeResult = audioProbeResult existingTrack.audioProbeResult = audioProbeResult
// TODO: Update data found from probe // TODO: Update data found from probe
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
wasUpdated = true wasUpdated = true
} }
} else { // Check if cover is empty } else { // Check if cover is empty
@@ -1,27 +1,55 @@
package com.audiobookshelf.app.media package com.audiobookshelf.app.media
import android.bluetooth.BluetoothClass
import android.content.Context import android.content.Context
import android.support.v4.media.MediaBrowserCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import java.util.* import java.util.*
import io.paperdb.Paper import io.paperdb.Paper
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) { class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager" val tag = "MediaManager"
var serverLibraryItems = listOf<LibraryItem>() var serverLibraryItems = listOf<LibraryItem>()
var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null
var serverPodcastEpisodes = listOf<PodcastEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>() var serverLibraryCategories = listOf<LibraryCategory>()
var serverLibraries = listOf<Library>() var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null
fun initializeAndroidAuto() { fun initializeAndroidAuto() {
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper") Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
Paper.init(ctx) Paper.init(ctx)
} }
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
}
fun checkResetServerItems() {
// When opening android auto need to check if still connected to server
// and reset any server data already set
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
serverPodcastEpisodes = listOf()
serverLibraryCategories = listOf()
serverLibraries = listOf()
serverLibraryItems = listOf()
selectedLibraryId = ""
}
}
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) { if (serverLibraryCategories.isNotEmpty()) {
cb(serverLibraryCategories) cb(serverLibraryCategories)
@@ -33,17 +61,75 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) { fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty()) { if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(serverLibraryItems) cb(serverLibraryItems)
} else { } else {
apiHandler.getLibraryItems(libraryId) { libraryItems -> apiHandler.getLibraryItems(libraryId) { libraryItems ->
serverLibraryItems = libraryItems val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
cb(libraryItems) if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId
serverLibraryItems = libraryItemsWithAudio
cb(libraryItemsWithAudio)
} }
} }
} }
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
if (libraryItemId.startsWith("local")) {
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
} else {
Log.d(tag, "loadLibraryItem: $libraryItemId")
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
Log.d(tag, "loadLibraryItem: Got library item $libraryItem")
cb(libraryItem)
}
}
}
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
selectedLibraryItemWrapper = libraryItemWrapper
libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
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)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
}
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
}
}
}
}
}
fun loadLibraries(cb: (List<Library>) -> Unit) { fun loadLibraries(cb: (List<Library>) -> Unit) {
if (serverLibraries.isNotEmpty()) { if (serverLibraries.isNotEmpty()) {
cb(serverLibraries) cb(serverLibraries)
@@ -55,6 +141,48 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
var successfulPing = false
suspendCoroutine<Boolean> { cont ->
apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it
cont.resume(it)
}
}
return successfulPing
}
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
if (!apiHandler.isOnline()) cb(false)
else {
coroutineScope {
var hasValidConn = false
// 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) {
// Loop through available configs and check if can connect
for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) {
val result = checkServerConnection(config)
if (result) {
hasValidConn = true
DeviceManager.serverConnectionConfig = config
break
}
}
}
cb(hasValidConn)
}
}
}
// TODO: Load currently listening category for local items // TODO: Load currently listening category for local items
fun loadLocalCategory():List<LibraryCategory> { fun loadLocalCategory():List<LibraryCategory> {
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
@@ -69,45 +197,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return cats return cats
} }
fun loadAndroidAutoItems(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
Log.d(tag, "Load android auto items for library id $libraryId") Log.d(tag, "Load android auto items")
val cats = mutableListOf<LibraryCategory>() val cats = mutableListOf<LibraryCategory>()
val localCategories = loadLocalCategory() val localCategories = loadLocalCategory()
cats.addAll(localCategories) cats.addAll(localCategories)
// Connected to server and has internet - load other cats // Check if any valid server connection if not use locally downloaded books
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) { checkSetValidServerConnectionConfig { isConnected ->
if (!DeviceManager.isConnectedToServer) { if (isConnected) {
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig() serverConfigIdUsed = DeviceManager.serverConnectionConfigId
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
}
loadLibraries { libraries -> loadLibraries { libraries ->
val library = libraries.find { it.id == libraryId } ?: libraries[0] val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(libraryId) { libraryCategories -> loadLibraryCategories(library.id) { libraryCategories ->
// Only using book or podcast library categories for now // Only using book or podcast library categories for now
libraryCategories.forEach { libraryCategories.forEach {
Log.d(tag, "Found library category ${it.label} with type ${it.type}") // Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) { if (it.type == library.mediaType) {
Log.d(tag, "Using library category ${it.id}") // Log.d(tag, "Using library category ${it.id}")
cats.add(it) cats.add(it)
}
} }
}
loadLibraryItems(libraryId) { libraryItems ->
val mainCat = LibraryCategory("library", "Library", library.mediaType, libraryItems, false)
cats.add(mainCat)
cb(cats) cb(cats)
} }
} }
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
} }
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
} }
} }
@@ -120,6 +242,19 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else {
val podcastEpisode = serverPodcastEpisodes.find { it.id == id }
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
} else {
null
}
}
}
fun getById(id:String) : LibraryItemWrapper? { fun getById(id:String) : LibraryItemWrapper? {
if (id.startsWith("local")) { if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItem(id) return DeviceManager.dbManager.getLocalLibraryItem(id)
@@ -135,13 +270,13 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun play(libraryItemWrapper:LibraryItemWrapper, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) { if (libraryItemWrapper is LocalLibraryItem) {
val localLibraryItem = libraryItemWrapper as LocalLibraryItem val localLibraryItem = libraryItemWrapper as LocalLibraryItem
cb(localLibraryItem.getPlaybackSession(null)) cb(localLibraryItem.getPlaybackSession(episode))
} else { } else {
val libraryItem = libraryItemWrapper as LibraryItem val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,"",false, mediaPlayer) { apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "",playItemRequestPayload) {
cb(it) cb(it)
} }
} }
@@ -7,14 +7,15 @@ import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.Library
import com.audiobookshelf.app.data.LibraryCategory import com.audiobookshelf.app.data.LibraryCategory
import com.audiobookshelf.app.data.LibraryItem import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalLibraryItem import com.audiobookshelf.app.data.LocalLibraryItem
class BrowseTree( class BrowseTree(
val context: Context, val context: Context,
libraryCategories: List<LibraryCategory> libraryCategories: List<LibraryCategory>,
libraries: List<Library>
) { ) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>() private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@@ -41,18 +42,18 @@ class BrowseTree(
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
}.build() }.build()
val allMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_books).toString())
}.build()
val downloadsMetadata = MediaMetadataCompat.Builder().apply { val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
}.build() }.build()
val librariesMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
}.build()
// Server continue Listening cat // Server continue Listening cat
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory -> libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw -> val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
@@ -69,30 +70,32 @@ class BrowseTree(
} }
} }
rootList += allMetadata if (libraries.isNotEmpty()) {
rootList += downloadsMetadata rootList += librariesMetadata
// Server library cat libraries.forEach { library ->
libraryCategories.find { it.id == "library" }?.let { libraryCategory -> val libraryMediaMetadata = library.getMediaMetadata()
val libraryMediaMetadata = libraryCategory.entities.map { libc -> val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
val libraryItem = libc as LibraryItem children += libraryMediaMetadata
libraryItem.getMediaMetadata() mediaIdToChildren[LIBRARIES_ROOT] = children
}
libraryMediaMetadata.forEach {
val children = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[ALL_ROOT] = children
} }
} }
rootList += downloadsMetadata
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat -> libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
val localMediaMetadata = localBooksCat.entities.map { libc -> localBooksCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem val libraryItem = libc as LocalLibraryItem
libraryItem.getMediaMetadata(context) val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context)
mediaIdToChildren[DOWNLOADS_ROOT] = children
} }
localMediaMetadata.forEach { }
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
localPodcastsCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf() val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += it children += libraryItem.getMediaMetadata(context)
mediaIdToChildren[DOWNLOADS_ROOT] = children mediaIdToChildren[DOWNLOADS_ROOT] = children
} }
} }
@@ -104,6 +107,6 @@ class BrowseTree(
} }
const val AUTO_BROWSE_ROOT = "/" const val AUTO_BROWSE_ROOT = "/"
const val ALL_ROOT = "__ALL__"
const val CONTINUE_ROOT = "__CONTINUE__" const val CONTINUE_ROOT = "__CONTINUE__"
const val DOWNLOADS_ROOT = "__DOWNLOADS__" const val DOWNLOADS_ROOT = "__DOWNLOADS__"
const val LIBRARIES_ROOT = "__LIBRARIES__"
@@ -4,6 +4,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.LocalMediaProgress import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
@@ -24,6 +25,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
var listeningTimerRunning:Boolean = false var listeningTimerRunning:Boolean = false
private var lastSyncTime:Long = 0 private var lastSyncTime:Long = 0
private var failedSyncs:Int = 0
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null var currentLocalMediaProgress: LocalMediaProgress? = null
@@ -41,6 +43,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress = null currentLocalMediaProgress = null
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
lastSyncTime = 0L lastSyncTime = 0L
failedSyncs = 0
} else { } else {
return return
} }
@@ -68,6 +71,16 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
reset() reset()
} }
fun syncFromServerProgress(mediaProgress: MediaProgress) {
currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
}
}
fun sync(currentTime:Double) { fun sync(currentTime:Double) {
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) { if (diffSinceLastSync < 1000L) {
@@ -100,7 +113,17 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
} }
} else { } else {
apiHandler.sendProgressSync(currentSessionId, syncData) { apiHandler.sendProgressSync(currentSessionId, syncData) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime") if (it) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.d(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
}
} }
} }
} }
@@ -130,5 +153,6 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentPlaybackSession = null currentPlaybackSession = null
currentLocalMediaProgress = null currentLocalMediaProgress = null
lastSyncTime = 0L lastSyncTime = 0L
failedSyncs = 0
} }
} }
@@ -9,11 +9,8 @@ import android.os.Message
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import kotlinx.coroutines.Dispatchers import com.audiobookshelf.app.data.PodcastEpisode
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
@@ -27,7 +24,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPrepare() { override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null) playerNotificationService.preparePlayer(it,true,null)
@@ -49,7 +46,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromSearch(query: String?, extras: Bundle?) { override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM SEARCH $query") Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null) playerNotificationService.preparePlayer(it,true,null)
@@ -90,14 +87,24 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var libraryItemWrapper: LibraryItemWrapper? = null var libraryItemWrapper: LibraryItemWrapper? = null
var podcastEpisode: PodcastEpisode? = null
if (mediaId.isNullOrEmpty()) { if (mediaId.isNullOrEmpty()) {
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem() libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else { } else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId) libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
}
} }
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null) playerNotificationService.preparePlayer(it,true,null)
@@ -112,10 +119,13 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
fun handleCallMediaButton(intent: Intent): Boolean { fun handleCallMediaButton(intent: Intent): Boolean {
if(Intent.ACTION_MEDIA_BUTTON == intent.action) { if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
when (keyEvent?.getKeyCode()) { if (keyEvent?.action == KeyEvent.ACTION_UP) {
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_HEADSETHOOK -> { KeyEvent.KEYCODE_HEADSETHOOK -> {
Log.d(tag, "handleCallMediaButton: Headset Hook")
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
if (playerNotificationService.mPlayer.isPlaying) if (playerNotificationService.mPlayer.isPlaying)
playerNotificationService.pause() playerNotificationService.pause()
@@ -125,6 +135,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PLAY -> { KeyEvent.KEYCODE_MEDIA_PLAY -> {
Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
playerNotificationService.play() playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer() playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
@@ -132,6 +143,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Pause")
if (0 == mediaButtonClickCount) playerNotificationService.pause() if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
@@ -145,6 +157,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
playerNotificationService.closePlayback() playerNotificationService.closePlayback()
} }
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
if (playerNotificationService.mPlayer.isPlaying) { if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause() if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount() handleMediaButtonClickCount()
@@ -157,7 +170,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
} }
else -> { else -> {
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}") Log.d(tag, "KeyCode:${keyEvent.keyCode}")
return false return false
} }
} }
@@ -166,7 +179,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return true return true
} }
fun handleMediaButtonClickCount() { private fun handleMediaButtonClickCount() {
mediaButtonClickCount++ mediaButtonClickCount++
if (1 == mediaButtonClickCount) { if (1 == mediaButtonClickCount) {
Timer().schedule(mediaButtonClickTimeout) { Timer().schedule(mediaButtonClickTimeout) {
@@ -7,8 +7,8 @@ import android.os.Looper
import android.os.ResultReceiver import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
@@ -30,7 +30,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepare(playWhenReady: Boolean) { override fun onPrepare(playWhenReady: Boolean) {
Log.d(tag, "ON PREPARE $playWhenReady") Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null) playerNotificationService.preparePlayer(it,playWhenReady,null)
} }
@@ -41,9 +41,19 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
var libraryItemWrapper: LibraryItemWrapper? = playerNotificationService.mediaManager.getById(mediaId) var libraryItemWrapper: LibraryItemWrapper? = null
var podcastEpisode: PodcastEpisode? = null
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
}
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null) playerNotificationService.preparePlayer(it,playWhenReady,null)
@@ -55,7 +65,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query") Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null) playerNotificationService.preparePlayer(it,playWhenReady,null)
@@ -5,6 +5,8 @@ import com.audiobookshelf.app.data.PlayerState
import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener" var tag = "PlayerListener"
@@ -15,7 +17,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
private var onSeekBack: Boolean = false private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
var errorMessage = error.message ?: "Unknown Error" val errorMessage = error.message ?: "Unknown Error"
Log.e(tag, "onPlayerError $errorMessage") Log.e(tag, "onPlayerError $errorMessage")
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
} }
@@ -81,6 +83,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
Log.d(tag, "SeekBackTime: back time is 0") Log.d(tag, "SeekBackTime: back time is 0")
} }
} }
// Check if playback session still exists or sync media progress if updated
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress()
if (!shouldCarryOn) return
}
} }
} else { } else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time") Log.d(tag, "SeekBackTime: Player not playing set last pause time")
@@ -90,6 +99,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
// Start/stop progress sync interval // Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}") Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
if (player.isPlaying) { if (player.isPlaying) {
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.start() playerNotificationService.mediaProgressSyncer.start()
} else { } else {
playerNotificationService.mediaProgressSyncer.stop() playerNotificationService.mediaProgressSyncer.stop()
@@ -101,8 +111,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
private fun calcPauseSeekBackTime() : Long { private fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0 if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime val time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long val seekback: Long
if (time < 3000) seekback = 0 if (time < 3000) seekback = 0
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
@@ -17,7 +17,9 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
@@ -46,12 +48,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun onPlaybackClosed() fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean) fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: PlaybackMetadata) fun onMetadata(metadata: PlaybackMetadata)
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition: Long) fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int) fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
fun onPlaybackFailed(errorMessage:String) fun onPlaybackFailed(errorMessage:String)
fun onMediaPlayerChanged(mediaPlayer:String) fun onMediaPlayerChanged(mediaPlayer:String)
fun onProgressSyncFailing()
} }
private val tag = "PlayerService" private val tag = "PlayerService"
@@ -66,7 +68,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var transportControls:MediaControllerCompat.TransportControls private lateinit var transportControls:MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager lateinit var mediaManager: MediaManager
private lateinit var apiHandler: ApiHandler lateinit var apiHandler: ApiHandler
lateinit var mPlayer: ExoPlayer lateinit var mPlayer: ExoPlayer
lateinit var currentPlayer:Player lateinit var currentPlayer:Player
@@ -356,7 +358,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}") Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
currentPlayer.playWhenReady = playWhenReady currentPlayer.playWhenReady = playWhenReady
currentPlayer.setPlaybackSpeed(playbackRateToUse) currentPlayer.setPlaybackSpeed(playbackRateToUse)
currentPlayer.prepare() currentPlayer.prepare()
} else if (castPlayer != null) { } else if (castPlayer != null) {
val currentTrackIndex = playbackSession.getCurrentTrackIndex() val currentTrackIndex = playbackSession.getCurrentTrackIndex()
val currentTrackTime = playbackSession.getCurrentTrackTimeMs() val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
@@ -371,12 +375,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// On error and was attempting to direct play - fallback to transcode // On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession -> currentPlaybackSession?.let { playbackSession ->
if (playbackSession.isDirectPlay) { if (playbackSession.isDirectPlay) {
val mediaPlayer = getMediaPlayer() val playItemRequestPayload = getPlayItemRequestPayload(true)
Log.d(tag, "Fallback to transcode $mediaPlayer") Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer")
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null) preparePlayer(it, true, null)
} }
@@ -388,6 +392,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
fun startNewPlaybackSession() {
currentPlaybackSession?.let { playbackSession ->
val forceTranscode = playbackSession.isHLS // If already HLS then force
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
}
fun switchToPlayer(useCastPlayer: Boolean) { fun switchToPlayer(useCastPlayer: Boolean) {
val wasPlaying = currentPlayer.isPlaying val wasPlaying = currentPlayer.isPlaying
if (useCastPlayer) { if (useCastPlayer) {
@@ -479,6 +498,76 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.id return currentPlaybackSession?.id
} }
// Called from PlayerListener play event
// check with server if progress has updated since last play and sync progress update
fun checkCurrentSessionProgress():Boolean {
if (currentPlaybackSession == null) return true
currentPlaybackSession?.let { playbackSession ->
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
return true // carry on
}
if (playbackSession.isLocal) {
// Make sure this connection config exists
val serverConnectionConfig = DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId)
if (serverConnectionConfig == null) {
Log.d(tag, "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}")
return true // carry on
}
// Local playback session check if server has updated media progress
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId, serverConnectionConfig) { mediaProgress ->
if (mediaProgress != null && mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
Handler(Looper.getMainLooper()).post {
seekPlayer(playbackSession.currentTimeMs)
}
}
Handler(Looper.getMainLooper()).post {
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
} else {
// Streaming from server so check if playback session still exists on server
Log.d(
tag,
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
)
apiHandler.getPlaybackSession(playbackSession.id) {
if (it == null) {
Log.d(
tag,
"checkCurrentSessionProgress: Playback session does not exist on server - start new playback session"
)
Handler(Looper.getMainLooper()).post {
currentPlayer.pause()
startNewPlaybackSession()
}
} else {
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
Handler(Looper.getMainLooper()).post {
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
}
}
}
return false
}
fun play() { fun play() {
if (currentPlayer.isPlaying) { if (currentPlayer.isPlaying) {
Log.d(tag, "Already playing") Log.d(tag, "Already playing")
@@ -520,12 +609,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun seekForward(amount: Long) { fun seekForward(amount: Long) {
seekPlayer(getCurrentTime() + amount) seekPlayer(getCurrentTime() + amount)
// currentPlayer.seekTo(currentPlayer.currentPosition + amount)
} }
fun seekBackward(amount: Long) { fun seekBackward(amount: Long) {
seekPlayer(getCurrentTime() - amount) seekPlayer(getCurrentTime() - amount)
// currentPlayer.seekTo(currentPlayer.currentPosition - amount)
} }
fun setPlaybackSpeed(speed: Float) { fun setPlaybackSpeed(speed: Float) {
@@ -549,10 +636,29 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player" return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
} }
fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE
manufacturer: Google
model: Pixel 6
brand: google
sdkVersion: 32
appVersion: 0.9.46-beta
*/
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
}
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
}
fun getContext():Context { fun getContext():Context {
return ctx return ctx
} }
fun alertSyncFailing() {
clientEventEmitter?.onProgressSyncFailing()
}
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // MEDIA BROWSER STUFF (ANDROID AUTO)
// //
@@ -565,6 +671,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val AUTO_MEDIA_ROOT = "/" private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__" private val ALL_ROOT = "__ALL__"
private val LIBRARIES_ROOT = "__LIBRARIES__"
private lateinit var browseTree:BrowseTree private lateinit var browseTree:BrowseTree
@@ -590,6 +697,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.initializeAndroidAuto() mediaManager.initializeAndroidAuto()
isStarted = true isStarted = true
} }
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
isAndroidAuto = true isAndroidAuto = true
@@ -610,32 +718,66 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
Log.d(tag, "ON LOAD CHILDREN $parentMediaId") Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
val flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE var flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach() result.detach()
mediaManager.loadAndroidAutoItems("main") { libraryCategories -> if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
browseTree = BrowseTree(this, libraryCategories) Log.d(tag, "Loading podcast episodes")
val children = browseTree[parentMediaId]?.map { item -> mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
MediaBrowserCompat.MediaItem(item.description, flag) result.sendResult(it)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) } else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
}
// TODO: For using sub menus. Check if this is the root menu: mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
// if (AUTO_MEDIA_ROOT == parentMediaId) { val children = libraryItems.map { libraryItem ->
// build the MediaItem objects for the top level, val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
// and put them in the mediaItems list
// } else { if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
// examine the passed parentMediaId to see which submenu we're at, flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
// and put the children of that menu in the mediaItems list }
// }
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, flag)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
localPodcasts.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(localBrowseItems)
} else { // Load categories
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)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
} }
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach() result.detach()
mediaManager.loadAndroidAutoItems("main") { libraryCategories -> mediaManager.loadAndroidAutoItems() { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories) browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
val children = browseTree[ALL_ROOT]?.map { item -> val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
@@ -59,13 +59,6 @@ class AbsAudioPlayer : Plugin() {
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata))) notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
} }
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
val jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("playWhenReady", playWhenReady)
notifyListeners("onPrepareMedia", jsobj)
}
override fun onSleepTimerEnded(currentPosition: Long) { override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition) emit("onSleepTimerEnded", currentPosition)
} }
@@ -85,6 +78,10 @@ class AbsAudioPlayer : Plugin() {
override fun onMediaPlayerChanged(mediaPlayer:String) { override fun onMediaPlayerChanged(mediaPlayer:String) {
emit("onMediaPlayerChanged", mediaPlayer) emit("onMediaPlayerChanged", mediaPlayer)
} }
override fun onProgressSyncFailing() {
emit("onProgressSyncFailing", "")
}
}) })
} }
mainActivity.pluginCallback = foregroundServiceReady mainActivity.pluginCallback = foregroundServiceReady
@@ -191,9 +188,9 @@ class AbsAudioPlayer : Plugin() {
return call.resolve(JSObject()) return call.resolve(JSObject())
} }
} else { // Play library item from server } else { // Play library item from server
val mediaPlayer = playerNotificationService.getMediaPlayer() val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
@@ -6,6 +6,7 @@ import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.* import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -188,7 +189,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun removeLocalMediaProgress(call:PluginCall) { fun removeLocalMediaProgress(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString() val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId) DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve() call.resolve()
} }
@@ -204,6 +205,63 @@ class AbsDatabase : Plugin() {
} }
} }
// Updates received via web socket
// This function doesn't need to sync with the server also because this data is coming from the server
// If sending the localMediaProgressId then update existing media progress
// If sending localLibraryItemId then save new local media progress
@PluginMethod
fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) {
val serverMediaProgress = call.getObject("mediaProgress").toString()
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
var localMediaProgressId = call.getString("localMediaProgressId") ?: ""
val mediaProgress = jacksonMapper.readValue<MediaProgress>(serverMediaProgress)
if (localMediaProgressId == "") {
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem != null) {
localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = mediaProgress.episodeId)
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
} else {
Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found")
}
} else {
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId")
val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
call.resolve()
} else {
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
}
}
@PluginMethod @PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) { fun updateLocalMediaProgressFinished(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString() val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
@@ -277,7 +277,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null) var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest() var dlRequest = downloadItemPart.getDownloadRequest()
@@ -294,7 +294,7 @@ class AbsDownloader : Plugin() {
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again") Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else { } else {
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null) downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
dlRequest = downloadItemPart.getDownloadRequest() dlRequest = downloadItemPart.getDownloadRequest()
@@ -19,11 +19,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiHandler(var ctx:Context) { class ApiHandler(var ctx:Context) {
val tag = "ApiHandler" val tag = "ApiHandler"
private var client = OkHttpClient() private var defaultClient = OkHttpClient()
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var storageSharedPreferences: SharedPreferences? = null var storageSharedPreferences: SharedPreferences? = null
@@ -33,11 +35,14 @@ class ApiHandler(var ctx:Context) {
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>) data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int) data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) { fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
val request = Request.Builder() val request = Request.Builder()
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
.build() .build()
makeRequest(request, cb) makeRequest(request, httpClient, cb)
} }
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
@@ -46,7 +51,7 @@ class ApiHandler(var ctx:Context) {
val request = Request.Builder().post(requestBody) val request = Request.Builder().post(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build() .build()
makeRequest(request, cb) makeRequest(request, null, cb)
} }
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
@@ -55,7 +60,7 @@ class ApiHandler(var ctx:Context) {
val request = Request.Builder().patch(requestBody) val request = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build() .build()
makeRequest(request, cb) makeRequest(request, null, cb)
} }
fun isOnline(): Boolean { fun isOnline(): Boolean {
@@ -76,19 +81,28 @@ class ApiHandler(var ctx:Context) {
return false return false
} }
fun makeRequest(request:Request, cb: (JSObject) -> Unit) { fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
val client = httpClient ?: defaultClient
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT") Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace() e.printStackTrace()
cb(JSObject())
val jsobj = JSObject()
jsobj.put("error", "Failed to connect")
cb(jsobj)
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
if (!it.isSuccessful) throw IOException("Unexpected code $response") if (!it.isSuccessful) {
val jsobj = JSObject()
jsobj.put("error", "Unexpected code $response")
cb(jsobj)
return
}
val bodyString = it.body!!.string() val bodyString = it.body!!.string()
if (bodyString == "OK") { if (bodyString == "OK") {
cb(JSObject()) cb(JSObject())
} else { } else {
@@ -108,7 +122,7 @@ class ApiHandler(var ctx:Context) {
fun getLibraries(cb: (List<Library>) -> Unit) { fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper val mapper = jacksonMapper
getRequest("/api/libraries") { getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>() val libraries = mutableListOf<Library>()
if (it.has("value")) { if (it.has("value")) {
val array = it.getJSONArray("value") val array = it.getJSONArray("value")
@@ -122,7 +136,7 @@ class ApiHandler(var ctx:Context) {
} }
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) { fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") { getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString()) val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem) cb(libraryItem)
} }
@@ -131,14 +145,14 @@ class ApiHandler(var ctx:Context) {
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) { fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress" var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId" if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl) { getRequest(requestUrl, null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString()) val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem) cb(libraryItem)
} }
} }
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) { fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") { getRequest("/api/libraries/$libraryId/items?limit=100&minified=1", null, null) {
val items = mutableListOf<LibraryItem>() val items = mutableListOf<LibraryItem>()
if (it.has("results")) { if (it.has("results")) {
val array = it.getJSONArray("results") val array = it.getJSONArray("results")
@@ -152,7 +166,7 @@ class ApiHandler(var ctx:Context) {
} }
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized") { getRequest("/api/libraries/$libraryId/personalized", null, null) {
val items = mutableListOf<LibraryCategory>() val items = mutableListOf<LibraryCategory>()
if (it.has("value")) { if (it.has("value")) {
val array = it.getJSONArray("value") val array = it.getJSONArray("value")
@@ -172,13 +186,8 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession) -> Unit) {
val payload = JSObject() val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
payload.put("mediaPlayer", mediaPlayer)
// Only if direct play fails do we force transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId" val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
postRequest(endpoint, payload) { postRequest(endpoint, payload) {
@@ -189,11 +198,15 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) { fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(syncData)) val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) { postRequest("/api/session/$sessionId/sync", payload) {
cb() if (!it.getString("error").isNullOrEmpty()) {
cb(false)
} else {
cb(true)
}
} }
} }
@@ -257,4 +270,45 @@ class ApiHandler(var ctx:Context) {
cb() cb()
} }
} }
fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) {
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
// TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
getRequest(endpoint, pingClient, serverConnectionConfig) {
if (it.has("error")) {
Log.e(tag, "getMediaProgress: Failed to get progress")
cb(null)
} else {
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
cb(progress)
}
}
}
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
val endpoint = "/api/session/$playbackSessionId"
getRequest(endpoint, null, null) {
val err = it.getString("error")
if (!err.isNullOrEmpty()) {
cb(null)
} else {
cb(jacksonMapper.readValue<PlaybackSession>(it.toString()))
}
}
}
fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) {
Log.d(tag, "pingServer: Pinging ${config.address}")
getRequest("/ping", pingClient, config) {
val success = it.getString("success")
if (success.isNullOrEmpty()) {
Log.d(tag, "pingServer: Ping ${config.address} Failed")
cb(false)
} else {
Log.d(tag, "pingServer: Ping ${config.address} Successful")
cb(true)
}
}
}
} }
@@ -10,9 +10,6 @@
android:translateY="-1.5294118"> android:translateY="-1.5294118">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/> android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,12l-2.5,-1.5L15,12L15,4h5v8z"/>
</group> </group>
</vector> </vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

+2 -5
View File
@@ -7,7 +7,7 @@
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer"> <a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
<span class="material-icons text-3xl text-white">arrow_back</span> <span class="material-icons text-3xl text-white">arrow_back</span>
</a> </a>
<div v-if="user"> <div v-if="user && currentLibrary">
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal"> <div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" /> <widgets-library-icon :icon="currentLibraryIcon" :size="4" />
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p> <p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p>
@@ -51,14 +51,11 @@ export default {
this.$store.commit('setCastAvailable', val) this.$store.commit('setCastAvailable', val)
} }
}, },
socketConnected() {
return this.$store.state.socketConnected
},
currentLibrary() { currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary'] return this.$store.getters['libraries/getCurrentLibrary']
}, },
currentLibraryName() { currentLibraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'Main' return this.currentLibrary ? this.currentLibrary.name : ''
}, },
currentLibraryIcon() { currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon : 'database' return this.currentLibrary ? this.currentLibrary.icon : 'database'
+63 -10
View File
@@ -74,10 +74,11 @@
</div> </div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3"> <div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @click="clickTrack"> <div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" /> <div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" /> <div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
</div> </div>
<div id="timestamp-row" class="flex pt-0.5"> <div id="timestamp-row" class="flex pt-0.5">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
@@ -132,7 +133,9 @@ export default {
touchStartTime: 0, touchStartTime: 0,
touchEndY: 0, touchEndY: 0,
useChapterTrack: false, useChapterTrack: false,
isLoading: false isLoading: false,
touchTrackStart: false,
dragPercent: 0
} }
}, },
computed: { computed: {
@@ -268,6 +271,10 @@ export default {
} }
}, },
methods: { methods: {
touchstartTrack(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
this.touchTrackStart = true
},
selectChapter(chapter) { selectChapter(chapter) {
this.seek(chapter.start) this.seek(chapter.start)
this.showChapterModal = false this.showChapterModal = false
@@ -517,15 +524,60 @@ export default {
this.touchStartTime = Date.now() this.touchStartTime = Date.now()
}, },
touchend(e) { touchend(e) {
if (!this.showFullscreen || !e.changedTouches) return if (!e.changedTouches) return
this.touchEndY = e.changedTouches[0].screenY if (this.touchTrackStart) {
var touchDuration = Date.now() - this.touchStartTime var touch = e.changedTouches[0]
if (touchDuration > 1200) { const touchOnTrackPos = touch.pageX - 12
// console.log('touch too long', touchDuration) const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
return
var seekToTime = 0
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
seekToTime = this.currentChapter.start + currChapTime
} else {
seekToTime = dragPercent * this.totalDuration
}
this.seek(seekToTime)
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = '0px'
}
this.touchTrackStart = false
} else if (this.showFullscreen) {
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
}
this.handleGesture()
}
},
touchmove(e) {
if (!this.touchTrackStart) return
var touch = e.touches[0]
const touchOnTrackPos = touch.pageX - 12
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
this.dragPercent = dragPercent
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = this.dragPercent * this.trackWidth + 'px'
}
var ts = this.$refs.currentTimestamp
if (ts) {
var currTimeStr = ''
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
currTimeStr = this.$secondsToTimestamp(currChapTime)
} else {
const dragTime = dragPercent * this.totalDuration
currTimeStr = this.$secondsToTimestamp(dragTime)
}
ts.innerText = currTimeStr
} }
this.handleGesture()
}, },
clickMenuAction(action) { clickMenuAction(action) {
if (action === 'chapter_track') { if (action === 'chapter_track') {
@@ -632,7 +684,7 @@ export default {
mounted() { mounted() {
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend) document.body.addEventListener('touchend', this.touchend)
document.body.addEventListener('touchmove', this.touchmove)
this.$nextTick(this.init) this.$nextTick(this.init)
}, },
beforeDestroy() { beforeDestroy() {
@@ -644,6 +696,7 @@ export default {
this.forceCloseDropdownMenu() this.forceCloseDropdownMenu()
document.body.removeEventListener('touchstart', this.touchstart) document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove() if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove() if (this.onMetadataListener) this.onMetadataListener.remove()
+9 -1
View File
@@ -30,9 +30,11 @@ export default {
onSleepTimerEndedListener: null, onSleepTimerEndedListener: null,
onSleepTimerSetListener: null, onSleepTimerSetListener: null,
onMediaPlayerChangedListener: null, onMediaPlayerChangedListener: null,
onProgressSyncFailing: null,
sleepInterval: null, sleepInterval: null,
currentEndOfChapterTime: 0, currentEndOfChapterTime: 0,
serverLibraryItemId: null serverLibraryItemId: null,
syncFailedToast: null
} }
}, },
watch: { watch: {
@@ -241,6 +243,10 @@ export default {
onMediaPlayerChanged(data) { onMediaPlayerChanged(data) {
var mediaPlayer = data.value var mediaPlayer = data.value
this.$store.commit('setMediaPlayer', mediaPlayer) this.$store.commit('setMediaPlayer', mediaPlayer)
},
showProgressSyncIsFailing() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced', { timeout: false, type: 'error' })
} }
}, },
mounted() { mounted() {
@@ -248,6 +254,7 @@ export default {
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
@@ -264,6 +271,7 @@ export default {
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
// if (this.$server.socket) { // if (this.$server.socket) {
// this.$server.socket.off('stream_open', this.streamOpen) // this.$server.socket.off('stream_open', this.streamOpen)
+7 -6
View File
@@ -74,9 +74,6 @@ export default {
username() { username() {
return this.user ? this.user.username : '' return this.user ? this.user.username : ''
}, },
socketConnected() {
return this.$store.state.socketConnected
},
navItems() { navItems() {
var items = [ var items = [
{ {
@@ -125,11 +122,15 @@ export default {
this.show = false this.show = false
}, },
async logout() { async logout() {
await this.$axios.$post('/logout').catch((error) => { if (this.user) {
console.error(error) await this.$axios.$post('/logout').catch((error) => {
}) console.error(error)
})
}
this.$socket.logout() this.$socket.logout()
await this.$db.logout() await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout') this.$store.commit('user/logout')
this.$router.push('/connect') this.$router.push('/connect')
}, },
+24 -1
View File
@@ -25,6 +25,7 @@ export default {
mixins: [bookshelfCardsHelpers], mixins: [bookshelfCardsHelpers],
data() { data() {
return { return {
routeFullPath: null,
entitiesPerShelf: 2, entitiesPerShelf: 2,
bookshelfHeight: 0, bookshelfHeight: 0,
bookshelfWidth: 0, bookshelfWidth: 0,
@@ -72,6 +73,7 @@ export default {
return this.page return this.page
}, },
hasFilter() { hasFilter() {
if (this.page === 'series' || this.page === 'collections') return false
return this.filterBy !== 'all' return this.filterBy !== 'all'
}, },
orderBy() { orderBy() {
@@ -316,6 +318,15 @@ export default {
await this.loadPage(0) await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex) this.mountEntites(0, lastBookIndex)
// Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window['bookshelf-wrapper']) {
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
if (path === this.routeFullPath) {
// Exact path match with query so use scroll position
window['bookshelf-wrapper'].scrollTop = scrollTop
}
}
}, },
scroll(e) { scroll(e) {
if (!e || !e.target) return if (!e || !e.target) return
@@ -358,6 +369,8 @@ export default {
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) { if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl) window.history.replaceState({ path: newurl }, '', newurl)
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
return true return true
} }
@@ -369,7 +382,7 @@ export default {
this.resetEntities() this.resetEntities()
} }
}, },
libraryChanged(libid) { libraryChanged() {
if (this.hasFilter) { if (this.hasFilter) {
this.clearFilter() this.clearFilter()
} else { } else {
@@ -452,12 +465,22 @@ export default {
this.$socket.$off('items_added', this.libraryItemsAdded) this.$socket.$off('items_added', this.libraryItemsAdded)
} }
}, },
updated() {
this.routeFullPath = window.location.pathname + (window.location.search || '')
},
mounted() { mounted() {
this.routeFullPath = window.location.pathname + (window.location.search || '')
this.init() this.init()
this.initListeners() this.initListeners()
}, },
beforeDestroy() { beforeDestroy() {
this.removeListeners() this.removeListeners()
// Set bookshelf scroll position for specific bookshelf page and query
if (window['bookshelf-wrapper']) {
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window['bookshelf-wrapper'].scrollTop || 0, path: this.routeFullPath, name: this.page })
}
} }
} }
</script> </script>
+2 -2
View File
@@ -5,7 +5,7 @@
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
</div> </div>
<img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="showCoverBg && !hasCover ? 'object-contain' : 'object-fill'" /> <img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="showCoverBg && hasCover ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
@@ -121,7 +121,7 @@ export default {
return this.media.coverPath || this.placeholderUrl return this.media.coverPath || this.placeholderUrl
}, },
hasCover() { hasCover() {
return !!this.media.coverPath || this.localCover || this.downloadCover return (!!this.media.coverPath && !this.isLocal) || this.localCover || this.downloadCover
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 128 : 96 var baseSize = this.squareAspectRatio ? 128 : 96
+3 -2
View File
@@ -10,7 +10,7 @@
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3"> <div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div> </div>
@@ -30,7 +30,8 @@ export default {
items: { items: {
type: Array, type: Array,
default: () => [] default: () => []
} },
selected: String // optional
}, },
data() { data() {
return {} return {}
+1 -1
View File
@@ -166,7 +166,7 @@ export default {
return this.filterData.narrators || [] return this.filterData.narrators || []
}, },
progress() { progress() {
return ['Finished', 'In Progress', 'Not Started'] return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
+52
View File
@@ -0,0 +1,52 @@
<template>
<modals-modal v-model="show" :width="400" height="100%">
<template #outer>
<div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Details</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
<p class="mb-1">{{ mediaMetadata.title }}</p>
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_libraryItem() {
return this.libraryItem || {}
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
}
},
methods: {},
mounted() {}
}
</script>
+1 -1
View File
@@ -51,7 +51,7 @@ export default {
async clickedOption(lib) { async clickedOption(lib) {
this.show = false this.show = false
await this.$store.dispatch('libraries/fetch', lib.id) await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed', lib.id) this.$eventBus.$emit('library-changed')
this.$localStore.setLastLibraryId(lib.id) this.$localStore.setLastLibraryId(lib.id)
} }
}, },
+4
View File
@@ -47,6 +47,10 @@ export default {
text: 'Size', text: 'Size',
value: 'size' value: 'size'
}, },
{
text: 'Duration',
value: 'media.duration'
},
{ {
text: 'File Birthtime', text: 'File Birthtime',
value: 'birthtimeMs' value: 'birthtimeMs'
+11 -4
View File
@@ -27,7 +27,7 @@
<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" />
<div v-if="!isIos"> <div v-if="!isIos && userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span> <span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span> <span v-else-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> <span v-else class="material-icons px-2 text-success text-xl">download_done</span>
@@ -41,7 +41,7 @@
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { AbsDownloader } from '@/plugins/capacitor' import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
export default { export default {
props: { props: {
@@ -69,6 +69,9 @@ export default {
mediaType() { mediaType() {
return 'podcast' return 'podcast'
}, },
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
audioFile() { audioFile() {
return this.episode.audioFile return this.episode.audioFile
}, },
@@ -132,8 +135,12 @@ export default {
} }
}, },
methods: { methods: {
selectFolder() { async selectFolder() {
this.$toast.error('Folder selector not implemented for podcasts yet') var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
return folderObj
}, },
downloadClick() { downloadClick() {
if (this.downloadItem) return if (this.downloadItem) return
+126 -4
View File
@@ -1,10 +1,28 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodes.length }})</p> <div class="flex items-center">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
<div class="flex-grow" />
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
<span class="material-icons text-xl text-gray-200">filter_alt</span>
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
</button>
<template v-for="episode in episodes"> <div class="flex items-center border border-white border-opacity-25 rounded px-2" @click="clickSort">
<p class="text-sm text-gray-200">{{ sortText }}</p>
<span class="material-icons ml-1 text-gray-200">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</div>
<template v-for="episode in episodesSorted">
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" /> <tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" />
</template> </template>
<!-- What in tarnation is going on here?
Without anything below the template it will not re-render -->
<p>&nbsp;</p>
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
</div> </div>
</template> </template>
@@ -24,9 +42,86 @@ export default {
isLocal: Boolean // If is local then episodes and libraryItemId are local, otherwise local is passed in localLibraryItemId and localEpisodes isLocal: Boolean // If is local then episodes and libraryItemId are local, otherwise local is passed in localLibraryItemId and localEpisodes
}, },
data() { data() {
return {} return {
episodesCopy: [],
showFiltersModal: false,
sortKey: 'publishedAt',
sortDesc: false,
filterKey: 'incomplete',
episodeSortItems: [
{
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Season',
value: 'season'
},
{
text: 'Episode',
value: 'episode'
}
],
filterItems: [
{
text: 'Show All',
value: 'all'
},
{
text: 'Incomplete',
value: 'incomplete'
},
{
text: 'In Progress',
value: 'inProgress'
},
{
text: 'Complete',
value: 'complete'
}
]
}
},
watch: {
episodes: {
immediate: true,
handler() {
this.init()
}
}
}, },
computed: { computed: {
episodesAreFiltered() {
return this.episodesFiltered.length !== this.episodesCopy.length
},
episodesFiltered() {
return this.episodesCopy.filter((ep) => {
var mediaProgress = this.getEpisodeProgress(ep)
if (this.filterKey === 'incomplete') {
return !mediaProgress || !mediaProgress.isFinished
} else if (this.filterKey === 'complete') {
return mediaProgress && mediaProgress.isFinished
} else if (this.filterKey === 'inProgress') {
return mediaProgress && !mediaProgress.isFinished
} else if (this.filterKey === 'all') {
console.log('Filter key is all')
return true
}
return true
})
},
episodesSorted() {
return this.episodesFiltered.sort((a, b) => {
if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
})
},
// Map of local episodes where server episode id is key // Map of local episodes where server episode id is key
localEpisodeMap() { localEpisodeMap() {
var epmap = {} var epmap = {}
@@ -36,9 +131,36 @@ export default {
} }
}) })
return epmap return epmap
},
sortText() {
if (!this.sortKey) return ''
var _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
if (!_sel) return ''
return _sel.text
}
},
methods: {
setFilter(filter) {
this.filterKey = filter
console.log('Set filter', this.filterKey)
this.showFiltersModal = false
},
showFilters() {
this.showFiltersModal = true
},
clickSort() {
this.sortDesc = !this.sortDesc
},
getEpisodeProgress(episode) {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
},
init() {
this.episodesCopy = this.episodes.map((ep) => {
return { ...ep }
})
} }
}, },
methods: {},
mounted() {} mounted() {}
} }
</script> </script>
+4 -4
View File
@@ -475,12 +475,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 = 7; CURRENT_PROJECT_VERSION = 9;
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.44; MARKETING_VERSION = 0.9.47;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -499,12 +499,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 = 7; CURRENT_PROJECT_VERSION = 9;
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.44; MARKETING_VERSION = 0.9.47;
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 = "";
+6 -6
View File
@@ -9,12 +9,12 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorApp', :path => '..\..\node_modules\@capacitor\app'
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog' pod 'CapacitorDialog', :path => '..\..\node_modules\@capacitor\dialog'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' pod 'CapacitorHaptics', :path => '..\..\node_modules\@capacitor\haptics'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar'
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage' pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage'
end end
target 'App' do target 'App' do
+1
View File
@@ -45,6 +45,7 @@ class AudioPlayer: NSObject {
self.playWhenReady = playWhenReady self.playWhenReady = playWhenReady
self.initialPlaybackRate = playbackRate self.initialPlaybackRate = playbackRate
self.audioPlayer = AVQueuePlayer() self.audioPlayer = AVQueuePlayer()
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
self.playbackSession = playbackSession self.playbackSession = playbackSession
self.status = -1 self.status = -1
self.rate = 0.0 self.rate = 0.0
+15 -2
View File
@@ -17,7 +17,7 @@ class ApiClient {
}).resume() }).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: Any], 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")
return return
@@ -27,7 +27,7 @@ class ApiClient {
"Authorization": "Bearer \(Store.serverConfig!.token)" "Authorization": "Bearer \(Store.serverConfig!.token)"
] ]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers).responseDecodable(of: decodable) { response in
switch response.result { switch response.result {
case .success(let obj): case .success(let obj):
callback?(obj) callback?(obj)
@@ -88,10 +88,23 @@ class ApiClient {
endpoint += "/\(episodeId!)" endpoint += "/\(episodeId!)"
} }
var systemInfo = utsname()
uname(&systemInfo)
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
ptr in String.init(validatingUTF8: ptr)
}
}
ApiClient.postResource(endpoint: endpoint, parameters: [ ApiClient.postResource(endpoint: endpoint, parameters: [
"forceDirectPlay": !forceTranscode ? "1" : "", "forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "", "forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer", "mediaPlayer": "AVPlayer",
"deviceInfo": [
"manufacturer": "Apple",
"model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
]
], decodable: PlaybackSession.self) { obj in ], decodable: PlaybackSession.self) { obj in
var session = obj var session = obj
+53 -2
View File
@@ -170,8 +170,8 @@ export default {
this.inittingLibraries = true this.inittingLibraries = true
await this.$store.dispatch('libraries/load') await this.$store.dispatch('libraries/load')
console.log(`[default] initLibraries loaded ${this.currentLibraryId}`) console.log(`[default] initLibraries loaded ${this.currentLibraryId}`)
await this.$store.dispatch('libraries/fetch', this.currentLibraryId)
this.$eventBus.$emit('library-changed') this.$eventBus.$emit('library-changed')
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
this.inittingLibraries = false this.inittingLibraries = false
}, },
async syncLocalMediaProgress() { async syncLocalMediaProgress() {
@@ -197,16 +197,66 @@ export default {
console.log('[default] syncLocalMediaProgress No local media progress to sync') console.log('[default] syncLocalMediaProgress No local media progress to sync')
} }
}, },
userUpdated(user) { async userUpdated(user) {
if (this.user && this.user.id == user.id) { if (this.user && this.user.id == user.id) {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
} }
},
async userMediaProgressUpdated(prog) {
console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`)
// Update local media progress if exists
var localProg = this.$store.getters['globals/getLocalMediaProgressByServerItemId'](prog.libraryItemId, prog.episodeId)
var newLocalMediaProgress = null
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
// Server progress is more up-to-date
console.log(`[default] syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''}`)
const payload = {
localMediaProgressId: localProg.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
} else {
// Check if local library item exists
var localLibraryItem = await this.$db.getLocalLibraryItemByLLId(prog.libraryItemId)
if (localLibraryItem) {
if (prog.episodeId) {
// If episode check if local episode exists
var lliEpisodes = localLibraryItem.media.episodes || []
var localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
if (localEpisode) {
// Add new local media progress
const payload = {
localLibraryItemId: localLibraryItem.id,
localEpisodeId: localEpisode.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
}
} else {
// Add new local media progress
const payload = {
localLibraryItemId: localLibraryItem.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
}
} else {
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
}
}
if (newLocalMediaProgress && newLocalMediaProgress.id) {
console.log(`[default] local media progress updated for ${newLocalMediaProgress.id}`)
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
}
} }
}, },
async mounted() { async mounted() {
this.$socket.on('connection-update', this.socketConnectionUpdate) this.$socket.on('connection-update', this.socketConnectionUpdate)
this.$socket.on('initialized', this.socketInit) this.$socket.on('initialized', this.socketInit)
this.$socket.on('user_updated', this.userUpdated) this.$socket.on('user_updated', this.userUpdated)
this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated)
if (this.$store.state.isFirstLoad) { if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false) this.$store.commit('setIsFirstLoad', false)
@@ -231,6 +281,7 @@ export default {
this.$socket.off('connection-update', this.socketConnectionUpdate) this.$socket.off('connection-update', this.socketConnectionUpdate)
this.$socket.off('initialized', this.socketInit) this.$socket.off('initialized', this.socketInit)
this.$socket.off('user_updated', this.userUpdated) this.$socket.off('user_updated', this.userUpdated)
this.$socket.off('user_media_progress_updated', this.userMediaProgressUpdated)
} }
} }
</script> </script>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.45-beta", "version": "0.9.49-beta",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.45-beta", "version": "0.9.49-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
+10 -4
View File
@@ -53,10 +53,16 @@ export default {
}, },
methods: { methods: {
async logout() { async logout() {
await this.$axios.$post('/logout').catch((error) => { if (this.user) {
console.error(error) await this.$axios.$post('/logout').catch((error) => {
}) console.error(error)
this.$server.logout() })
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$router.push('/connect') this.$router.push('/connect')
} }
}, },
-4
View File
@@ -4,10 +4,6 @@
<home-bookshelf-toolbar v-show="!isHome" /> <home-bookshelf-toolbar v-show="!isHome" />
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''"> <div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''">
<nuxt-child /> <nuxt-child />
<!-- <div v-if="isLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<ui-loading-indicator />
</div>-->
</div> </div>
</div> </div>
</template> </template>
+1 -1
View File
@@ -135,7 +135,7 @@ export default {
} }
this.loading = false this.loading = false
}, },
async libraryChanged(libid) { async libraryChanged() {
if (this.currentLibraryId) { if (this.currentLibraryId) {
await this.fetchCategories() await this.fetchCategories()
} }
+167 -59
View File
@@ -1,60 +1,100 @@
<template> <template>
<div class="w-full h-full px-3 py-4 overflow-y-auto"> <div class="w-full h-full px-3 py-4 overflow-y-auto">
<div class="flex"> <div class="flex">
<div class="w-32"> <div class="w-16">
<div class="relative"> <div class="relative">
<covers-book-cover :library-item="libraryItem" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="64" :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 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 64 * 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="title-container flex-grow pl-2">
<h1 class="text-lg">{{ title }}</h1> <div class="flex relative pr-6">
<h3 v-if="seriesName" class="text-gray-300 text-sm leading-6">{{ seriesName }}</h3> <h1 class="text-base">{{ title }}</h1>
<p class="text-sm text-gray-400">by {{ author }}</p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1"> <button class="absolute top-0 right-0 h-full px-1 outline-none" @click="moreButtonPress">
{{ $elapsedPretty(duration) }} <span class="material-icons text-xl">more_vert</span>
<span v-if="!isLocal" class="px-4">{{ $bytesPretty(size) }}</span> </button>
</div>
<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">,&nbsp;</span></template
>
</p> </p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1">{{ numTracks }} Tracks</p> <p v-if="podcastAuthor" class="text-sm text-gray-400 py-0.5">By {{ author }}</p>
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-400 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
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,&nbsp;</span></template
>
</p>
</div>
</div>
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''"> <p v-if="narrators && narrators.length" class="text-sm text-gray-400 py-0.5">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> Narrated By
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> <template v-for="(narrator, index) in narrators"
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p> ><nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`">{{ narrator }}</nuxt-link
<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 :key="`${narrator}-comma`" v-if="index < narrators.length - 1">,&nbsp;</span></template
<span class="material-icons text-sm">close</span> >
</div> </p>
</div>
<div v-if="isLocal" class="flex mt-4"> <!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick"> <p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p>
<span v-show="!isPlaying" class="material-icons">play_arrow</span> <p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play' }}</span>
</ui-btn> <div v-if="numTracks" class="flex text-gray-100 text-xs my-2 -mx-0.5">
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook"> <div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<span class="material-icons">auto_stories</span> <p>{{ $elapsedPretty(duration) }}</p>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span> </div>
</ui-btn> <!-- TODO: Local books dont save the size -->
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" /> <div v-if="size" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
</div> <p>{{ $bytesPretty(size) }}</p>
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4"> </div>
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick"> <div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<span v-show="!isPlaying" class="material-icons">play_arrow</span> <p>{{ numTracks }} Track{{ numTracks > 1 ? 's' : '' }}</p>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play' : 'Stream' }}</span> </div>
</ui-btn> <div v-if="numChapters" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<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"> <p>{{ numChapters }} Chapter{{ numChapters > 1 ? 's' : '' }}</p>
<span class="material-icons">auto_stories</span> </div>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span> </div>
</ui-btn>
<ui-btn v-if="user && showPlay && !hasLocal" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mr-2" :padding-x="2" @click="downloadClick"> <div>
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span> <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' : ''">
</ui-btn> <p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" /> <p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div> </div>
</div> </div>
<div v-if="isLocal" 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 ? 'Playing' : 'Play' }}</span>
</ui-btn>
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4">
<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>
</ui-btn>
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mr-2" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
</div> </div>
<div v-if="downloadItem" class="py-3"> <div v-if="downloadItem" class="py-3">
@@ -68,6 +108,10 @@
<tables-podcast-episodes-table v-if="isPodcast" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" /> <tables-podcast-episodes-table v-if="isPodcast" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" /> <modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
<modals-dialog v-model="showMoreMenu" title="" :items="moreMenuItems" @action="moreMenuAction" />
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
</div> </div>
</template> </template>
@@ -82,7 +126,7 @@ export default {
console.log(libraryItemId) console.log(libraryItemId)
if (libraryItemId.startsWith('local')) { if (libraryItemId.startsWith('local')) {
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId) libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
console.log('Got lli', libraryItem) console.log('Got lli', libraryItemId)
} else if (store.state.user.serverConnectionConfig) { } else if (store.state.user.serverConnectionConfig) {
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => { libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
@@ -110,13 +154,18 @@ export default {
return { return {
resettingProgress: false, resettingProgress: false,
isProcessingReadUpdate: false, isProcessingReadUpdate: false,
showSelectLocalFolder: false showSelectLocalFolder: false,
showMoreMenu: false,
showDetailsModal: false
} }
}, },
computed: { computed: {
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isLocal() { isLocal() {
return this.libraryItem.isLocal return this.libraryItem.isLocal
}, },
@@ -166,9 +215,17 @@ export default {
title() { title() {
return this.mediaMetadata.title return this.mediaMetadata.title
}, },
author() { podcastAuthor() {
if (this.isPodcast) return this.mediaMetadata.author if (!this.isPodcast) return null
return this.mediaMetadata.authorName return this.mediaMetadata.author || ''
},
bookAuthors() {
if (this.isPodcast) return null
return this.mediaMetadata.authors || []
},
narrators() {
if (this.isPodcast) return null
return this.mediaMetadata.narrators || []
}, },
description() { description() {
return this.mediaMetadata.description || '' return this.mediaMetadata.description || ''
@@ -176,9 +233,16 @@ export default {
series() { series() {
return this.mediaMetadata.series || [] return this.mediaMetadata.series || []
}, },
seriesName() { seriesList() {
// For books only on toJSONExpanded if (this.isPodcast) return null
return this.mediaMetadata.seriesName || '' return this.series.map((se) => {
var text = se.name
if (se.sequence) text += ` #${se.sequence}`
return {
...se,
text
}
})
}, },
duration() { duration() {
return this.media.duration return this.media.duration
@@ -223,6 +287,10 @@ export default {
if (!this.media.tracks) return 0 if (!this.media.tracks) return 0
return this.media.tracks.length || 0 return this.media.tracks.length || 0
}, },
numChapters() {
if (!this.media.chapters) return 0
return this.media.chapters.length || 0
},
isMissing() { isMissing() {
return this.libraryItem.isMissing return this.libraryItem.isMissing
}, },
@@ -235,6 +303,10 @@ export default {
showRead() { showRead() {
return this.ebookFile && this.ebookFormat !== 'pdf' return this.ebookFile && this.ebookFormat !== 'pdf'
}, },
showDownload() {
if (this.isIos) return false
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
},
ebookFile() { ebookFile() {
return this.media.ebookFile return this.media.ebookFile
}, },
@@ -242,9 +314,6 @@ export default {
if (!this.ebookFile) return null if (!this.ebookFile) return null
return this.ebookFile.ebookFormat return this.ebookFile.ebookFormat
}, },
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadItem() { downloadItem() {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId) return this.$store.getters['globals/getDownloadItem'](this.libraryItemId)
}, },
@@ -253,9 +322,41 @@ export default {
}, },
isCasting() { isCasting() {
return this.$store.state.isCasting return this.$store.state.isCasting
},
moreMenuItems() {
if (this.isLocal) {
return [
{
text: 'Manage Local Files',
value: 'manageLocal'
},
{
text: 'View Details',
value: 'details'
}
]
} else {
return [
{
text: 'View Details',
value: 'details'
}
]
}
} }
}, },
methods: { methods: {
moreMenuAction(action) {
this.showMoreMenu = false
if (action === 'manageLocal') {
this.$router.push(`/localMedia/item/${this.libraryItemId}`)
} else if (action === 'details') {
this.showDetailsModal = true
}
},
moreButtonPress() {
this.showMoreMenu = true
},
readBook() { readBook() {
this.$store.commit('openReader', this.libraryItem) this.$store.commit('openReader', this.libraryItem)
}, },
@@ -279,7 +380,7 @@ export default {
if (this.isLocal) { if (this.isLocal) {
// TODO: If connected to server also sync with server // TODO: If connected to server also sync with server
await this.$db.removeLocalMediaProgress(this.libraryItemId) await this.$db.removeLocalMediaProgress(this.libraryItemId)
this.$store.commit('globals/removeLocalMediaProgress', this.libraryItemId) this.$store.commit('globals/removeLocalMediaProgressForItem', this.libraryItemId)
} else { } else {
var progressId = this.userItemProgress.id var progressId = this.userItemProgress.id
await this.$axios await this.$axios
@@ -442,4 +543,11 @@ export default {
this.$socket.off('item_updated', this.itemUpdated) this.$socket.off('item_updated', this.itemUpdated)
} }
} }
</script> </script>
<style>
.title-container {
width: calc(100% - 64px);
max-width: calc(100% - 64px);
}
</style>
+2 -2
View File
@@ -189,7 +189,7 @@ export default {
if (this.selectedAudioTrack || this.selectedEpisode) { if (this.selectedAudioTrack || this.selectedEpisode) {
return [ return [
{ {
text: 'Hard Delete', text: 'Remove & Delete Files',
value: 'track-delete' value: 'track-delete'
} }
] ]
@@ -208,7 +208,7 @@ export default {
value: 'remove' value: 'remove'
}, },
{ {
text: 'Hard Delete', text: 'Remove & Delete Files',
value: 'delete' value: 'delete'
} }
] ]
+2 -2
View File
@@ -187,6 +187,7 @@ class AbsAudioPlayerWeb extends WebPlugin {
return return
} }
this.player.currentTime = this.trackStartTime this.player.currentTime = this.trackStartTime
this.sendPlaybackMetadata(PlayerState.READY) this.sendPlaybackMetadata(PlayerState.READY)
if (this.playWhenReady) { if (this.playWhenReady) {
this.player.play() this.player.play()
@@ -195,10 +196,9 @@ class AbsAudioPlayerWeb extends WebPlugin {
evtTimeupdate() { } evtTimeupdate() { }
sendPlaybackMetadata(playerState) { sendPlaybackMetadata(playerState) {
var currentTime = this.player ? this.player.currentTime || 0 : 0
this.notifyListeners('onMetadata', { this.notifyListeners('onMetadata', {
duration: this.totalDuration, duration: this.totalDuration,
currentTime, currentTime: this.overallCurrentTime,
playerState playerState
}) })
} }
+4
View File
@@ -195,6 +195,10 @@ class AbsDatabaseWeb extends WebPlugin {
return null return null
} }
async syncServerMediaProgressWithLocalMediaProgress(payload) {
return null
}
async updateLocalTrackOrder({ localLibraryItemId, tracks }) { async updateLocalTrackOrder({ localLibraryItemId, tracks }) {
return [] return []
} }
+4
View File
@@ -70,6 +70,10 @@ class DbService {
return AbsDatabase.syncLocalMediaProgressWithServer() return AbsDatabase.syncLocalMediaProgressWithServer()
} }
syncServerMediaProgressWithLocalMediaProgress(payload) {
return AbsDatabase.syncServerMediaProgressWithLocalMediaProgress(payload)
}
updateLocalTrackOrder(payload) { updateLocalTrackOrder(payload) {
return AbsDatabase.updateLocalTrackOrder(payload) return AbsDatabase.updateLocalTrackOrder(payload)
} }
+10 -1
View File
@@ -83,7 +83,16 @@ class LocalStorage {
await Storage.set({ key: 'lastLibraryId', value: libraryId }) await Storage.set({ key: 'lastLibraryId', value: libraryId })
console.log('[LocalStorage] Set Last Library Id', libraryId) console.log('[LocalStorage] Set Last Library Id', libraryId)
} catch (error) { } catch (error) {
console.error('[LocalStorage] Failed to set current library', error) console.error('[LocalStorage] Failed to set last library id', error)
}
}
async removeLastLibraryId() {
try {
await Storage.remove({ key: 'lastLibraryId' })
console.log('[LocalStorage] Remove Last Library Id')
} catch (error) {
console.error('[LocalStorage] Failed to remove last library id', error)
} }
} }
+1
View File
@@ -91,6 +91,7 @@ class ServerSocket extends EventEmitter {
console.log('[SOCKET] User Item Progress Updated', JSON.stringify(data)) console.log('[SOCKET] User Item Progress Updated', JSON.stringify(data))
var progress = data.data var progress = data.data
this.$store.commit('user/updateUserMediaProgress', progress) this.$store.commit('user/updateUserMediaProgress', progress)
this.emit('user_media_progress_updated', progress)
} }
} }
+13 -1
View File
@@ -22,13 +22,16 @@ export const getters = {
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
var userToken = rootGetters['user/getToken'] var userToken = rootGetters['user/getToken']
var serverAddress = rootGetters['user/getServerAddress']
if (!userToken || !serverAddress) return placeholder
var lastUpdate = libraryItem.updatedAt || Date.now() var lastUpdate = libraryItem.updatedAt || Date.now()
if (process.env.NODE_ENV !== 'production') { // Testing if (process.env.NODE_ENV !== 'production') { // Testing
// return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}` // return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
} }
var url = new URL(`/api/items/${libraryItem.id}/cover`, rootGetters['user/getServerAddress']) var url = new URL(`/api/items/${libraryItem.id}/cover`, serverAddress)
return `${url}?token=${userToken}&ts=${lastUpdate}` return `${url}?token=${userToken}&ts=${lastUpdate}`
}, },
getLocalMediaProgressById: (state) => (localLibraryItemId, episodeId = null) => { getLocalMediaProgressById: (state) => (localLibraryItemId, episodeId = null) => {
@@ -36,6 +39,12 @@ export const getters = {
if (episodeId != null && lmp.localEpisodeId != episodeId) return false if (episodeId != null && lmp.localEpisodeId != episodeId) return false
return lmp.localLibraryItemId == localLibraryItemId return lmp.localLibraryItemId == localLibraryItemId
}) })
},
getLocalMediaProgressByServerItemId: (state) => (libraryItemId, episodeId = null) => {
return state.localMediaProgress.find(lmp => {
if (episodeId != null && lmp.episodeId != episodeId) return false
return lmp.libraryItemId == libraryItemId
})
} }
} }
@@ -84,6 +93,9 @@ export const mutations = {
removeLocalMediaProgress(state, id) { removeLocalMediaProgress(state, id) {
state.localMediaProgress = state.localMediaProgress.filter(lmp => lmp.id != id) state.localMediaProgress = state.localMediaProgress.filter(lmp => lmp.id != id)
}, },
removeLocalMediaProgressForItem(state, llid) {
state.localMediaProgress = state.localMediaProgress.filter(lmp => lmp.localLibraryItemId !== llid)
},
setLastSearch(state, val) { setLastSearch(state, val) {
state.lastSearch = val state.lastSearch = val
} }
+5 -1
View File
@@ -16,7 +16,8 @@ export const state = () => ({
showReader: false, showReader: false,
showSideDrawer: false, showSideDrawer: false,
isNetworkListenerInit: false, isNetworkListenerInit: false,
serverSettings: null serverSettings: null,
lastBookshelfScrollData: {}
}) })
export const getters = { export const getters = {
@@ -54,6 +55,9 @@ export const actions = {
} }
export const mutations = { export const mutations = {
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
state.lastBookshelfScrollData[name] = { scrollTop, path }
},
setPlayerItem(state, playbackSession) { setPlayerItem(state, playbackSession) {
state.playerIsLocal = playbackSession ? playbackSession.playMethod == this.$constants.PlayMethod.LOCAL : false state.playerIsLocal = playbackSession ? playbackSession.playMethod == this.$constants.PlayMethod.LOCAL : false
+1
View File
@@ -90,6 +90,7 @@ export const mutations = {
}, },
reset(state) { reset(state) {
state.lastLoad = 0 state.lastLoad = 0
state.currentLibraryId = null
state.libraries = [] state.libraries = []
}, },
setCurrentLibrary(state, val) { setCurrentLibrary(state, val) {
+3
View File
@@ -35,6 +35,9 @@ export const getters = {
}, },
getUserSetting: (state) => (key) => { getUserSetting: (state) => (key) => {
return state.settings ? state.settings[key] || null : null return state.settings ? state.settings[key] || null : null
},
getUserCanDownload: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.download : false
} }
} }