Compare commits

...
Author SHA1 Message Date
advplyr 0c49bcfe3f iOS version bump 0.9.44 2022-05-06 20:04:42 -05:00
advplyr aecb6a8dd2 Android version bump 0.9.44-beta 2022-05-06 20:01:01 -05:00
advplyr 718947522e Fix:Remove download notifications after download item finished #161 2022-05-06 19:45:07 -05:00
advplyr 2f8ca51447 Add:Vibrate when sleep timer is extended or reset by shaking #169 2022-05-06 19:28:07 -05:00
advplyr 4fa6dd2616 Fix:Cover crashing when using in media notification. Convert content uri for local image to File and pass main activity context instead of player service context #35 #164 2022-05-06 18:57:05 -05:00
advplyr 736e57fafd Add:Fallback to transcode when direct play fails, and send playback failed event to client 2022-05-06 18:17:45 -05:00
advplyr 30d86279a5 Update:Host fonts locally 2022-05-05 18:25:32 -05:00
advplyrandGitHub 8bbfcdeb82 Merge pull request #166 from rasmuslos/master
Added sleep timer
2022-05-05 07:49:44 -05:00
Rasmus Krämer d23cf62264 Fix sleep timer increase & decrease 2022-05-05 14:38:34 +02:00
advplyr 73d5b19d2b Update:Casting when playing local item if connected to server linked to local item #170, Update:Play button color when playing local item verse server item, Add:Show play method at the top of audio player (Direct, Local, Transcode) 2022-05-04 19:31:56 -05:00
Rasmus Krämer 1959351125 Removed unnecessary comment 2022-05-03 15:03:53 +02:00
Rasmus Krämer 0708133779 Fixed crash 2022-05-03 15:01:30 +02:00
Rasmus Krämer 9a81fc3688 Merge branch 'advplyr-master' 2022-05-03 14:42:20 +02:00
Rasmus Krämer ac71d39265 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app into advplyr-master 2022-05-03 14:41:46 +02:00
Rasmus Krämer 4203654ec8 Added sleep timer 2022-05-03 14:32:46 +02:00
Rasmus Krämer 9701c767b2 Small improvements 2022-05-03 12:55:13 +02:00
advplyrandGitHub 0223df4f9e Merge pull request #162 from benonymity/ListViewImprovements
Tweaks for bookshelf list view
2022-05-01 18:34:48 -05:00
benonymity 7549385404 Return null if not podcast 2022-05-01 18:27:02 -04:00
benonymity 3f2d0ed8b1 Display number of podcast episodes in list view 2022-05-01 18:03:37 -04:00
benonymity e4a5927e07 Tweaks for bookshelf list view 2022-05-01 16:49:56 -04:00
advplyr a3aac4da75 Update: iOS AudioPlayer direct play with multi-track using AVQueuePlayer 2022-05-01 12:19:31 -05:00
advplyr 1e9453e501 Fix:Check google services are available before initializing chromecast #158 2022-04-30 16:33:41 -05:00
advplyr bec3f5841e Fix:Setting local library item as finished when local media progress does not exist 2022-04-30 15:35:20 -05:00
advplyrandGitHub 068762912f Merge pull request #134 from benonymity/MarkAsRead
Add mark as read button
2022-04-30 14:58:45 -05:00
advplyr 01e85d0e91 Fix:Android replace reserved characters in filenames #154 2022-04-30 14:45:55 -05:00
benonymity a67c19f30f Shorten play button text 2022-04-30 15:01:27 -04:00
Rasmus Krämer 394363c8cb Send report when playback ends 2022-04-30 10:58:08 +02:00
benonymity 7fd51ebcc1 Fix button padding, final tests 2022-04-28 20:05:25 -04:00
advplyr 15b7fbce47 iOS version bump 0.9.43 2022-04-28 18:06:13 -05:00
advplyr e03f878865 iOS fix: Logging out when player is open crashing because server config is nil, added nil check in Api requests 2022-04-28 18:05:33 -05:00
advplyr cffa7f5344 Web and android version bump 0.9.43-beta 2022-04-28 17:44:44 -05:00
advplyr 8411428241 Fix:Auto rewind and jump forward/jump backward on multi-tracks #143 2022-04-28 17:19:02 -05:00
benonymity ae4678cf24 Resize button, no podcast downloads on iOS 2022-04-28 17:13:21 -04:00
benonymity 68e565ebe2 Don't show if a podcast 2022-04-28 16:38:27 -04:00
benonymity d99f4406b7 Removed episode reference, smaller play button 2022-04-28 13:21:47 -04:00
BenandGitHub 2064cd8380 Merge branch 'advplyr:master' into MarkAsRead 2022-04-28 12:24:44 -04:00
benonymity 415ff65561 Display when local, add CSS classes 2022-04-27 15:19:29 -04:00
advplyr be885009ad Change:persist last search term #142 2022-04-26 17:05:49 -05:00
advplyr d64dd63ea4 Fix:Close playback when logging out 2022-04-26 16:46:29 -05:00
advplyrandGitHub 72f10e5f42 Merge pull request #148 from selfhost-alt/fix-local-scanning-android
Handle audio probes that don't return any tags
2022-04-26 16:13:04 -05:00
Selfhost Alt 511386e80a Handle audio probes that don't return any tags 2022-04-26 10:32:14 -07:00
advplyr b219756cb7 Fix:Save local media progress when downloading library item by requesting user media progress with item from server (will require server update) #145 2022-04-25 19:01:20 -05:00
advplyr 31c753ffcc Fix:Downloading cover art query strings to set token and jpeg format #144 2022-04-25 17:30:59 -05:00
advplyr 2dd822e04d Fix: Sleep timer for multi-track playback sessions - use duration of all tracks stored in playback session instead of duration from exoplayer #140 2022-04-25 17:22:12 -05:00
advplyrandGitHub d18972e2f3 Merge pull request #132 from benonymity/DataClasses
New Data Classes
2022-04-25 07:27:22 -05:00
benonymity 81ca757c77 Book-Podcast combination, optional fixes 2022-04-25 00:15:44 -04:00
BenandGitHub 7189588c2b Merge branch 'advplyr:master' into DataClasses 2022-04-24 22:47:48 -04:00
benonymity 1fed00ca81 Add localLIbraryProgress updates 2022-04-24 22:02:22 -04:00
BenandGitHub a63022a669 Merge branch 'advplyr:master' into MarkAsRead 2022-04-24 21:40:01 -04:00
advplyr 843f6ca606 Update home page for displaying podcast episode shelves 2022-04-24 14:15:37 -05:00
advplyr 52fd8ac5e8 Fix crash when probing audio files by updating start/end chapter data type to Long #128, remove unused files and generic database save/load methods 2022-04-24 13:28:14 -05:00
benonymity 114dbd24bc Add button to mark book as read 2022-04-23 00:27:03 -04:00
benonymity 4d0d1eb88f New Data Classes 2022-04-22 20:31:45 -04:00
64 changed files with 1822 additions and 596 deletions
+2 -2
View File
@@ -29,8 +29,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 71
versionName "0.9.42-beta"
versionCode 73
versionName "0.9.44-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -2,11 +2,6 @@ package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.jsonschema.JsonSerializableSchema
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeStream(
@@ -27,15 +22,15 @@ data class AudioProbeChapterTags(
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeChapter(
val id:Int,
val start:Int,
val end:Int,
val start:Long,
val end:Long,
val tags:AudioProbeChapterTags?
) {
@JsonIgnore
fun getBookChapter():BookChapter {
var startS = start / 1000.0
var endS = end / 1000.0
var title = tags?.title ?: "Chapter $id"
val startS = start / 1000.0
val endS = end / 1000.0
val title = tags?.title ?: "Chapter $id"
return BookChapter(id, startS, endS, title)
}
}
@@ -57,7 +52,7 @@ data class AudioProbeFormat(
val duration:Double,
val size:Long,
val bit_rate:Double,
val tags:AudioProbeFormatTags
val tags:AudioProbeFormatTags?
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -68,8 +63,8 @@ class AudioProbeResult (
val duration get() = format.duration
val size get() = format.size
val title get() = format.tags.title ?: format.filename.split("/").last()
val artist get() = format.tags.artist ?: ""
val title get() = format.tags?.title ?: format.filename.split("/").last()
val artist get() = format.tags?.artist ?: ""
@JsonIgnore
fun getBookChapters(): List<BookChapter> {
@@ -25,7 +25,8 @@ data class LibraryItem(
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper() {
@get:JsonIgnore
val title get() = media.metadata.title
@@ -85,7 +86,7 @@ class Podcast(
) : MediaType(metadata, coverPath) {
@JsonIgnore
override fun getAudioTracks():List<AudioTrack> {
var tracks = episodes?.map { it.audioTrack }
val tracks = episodes?.map { it.audioTrack }
return tracks?.filterNotNull() ?: mutableListOf()
}
@JsonIgnore
@@ -98,7 +99,7 @@ class Podcast(
// Add new episodes
audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
var 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_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
episodes?.add(newEpisode)
}
}
@@ -111,7 +112,7 @@ class Podcast(
}
@JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) {
var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
episodes?.add(newEpisode)
var index = 1
@@ -131,8 +132,8 @@ class Podcast(
}
}
@JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode) {
var newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
episodes?.add(newEpisode)
var index = 1
@@ -140,6 +141,7 @@ class Podcast(
it.index = index
index++
}
return newEpisode
}
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@@ -355,3 +357,17 @@ data class BookChapter(
var end:Double,
var title:String?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
var progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
)
@@ -17,9 +17,9 @@ class DbManager {
}
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
var localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
Paper.book("localLibraryItems").allKeys.forEach {
var localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
val localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
localLibraryItems.add(localLibraryItem)
}
@@ -28,7 +28,7 @@ class DbManager {
}
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
var localLibraryItems = getLocalLibraryItems()
val localLibraryItems = getLocalLibraryItems()
return localLibraryItems.filter {
it.folderId == folderId
}
@@ -65,7 +65,7 @@ class DbManager {
}
fun getAllLocalFolders():List<LocalFolder> {
var localFolders:MutableList<LocalFolder> = mutableListOf()
val localFolders:MutableList<LocalFolder> = mutableListOf()
Paper.book("localFolders").allKeys.forEach { localFolderId ->
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
localFolders.add(it)
@@ -75,7 +75,7 @@ class DbManager {
}
fun removeLocalFolder(folderId:String) {
var localLibraryItems = getLocalLibraryItemsInFolder(folderId)
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
localLibraryItems.forEach {
Paper.book("localLibraryItems").delete(it.id)
}
@@ -91,7 +91,7 @@ class DbManager {
}
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
var downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
downloadItems.add(it)
@@ -108,7 +108,7 @@ class DbManager {
return Paper.book("localMediaProgress").read(localMediaProgressId)
}
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
var mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
mediaProgress.add(it)
@@ -126,14 +126,14 @@ class DbManager {
// Make sure all local file ids still exist
fun cleanLocalLibraryItems() {
var localLibraryItems = getLocalLibraryItems()
val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli ->
var hasUpates = false
// Check local files
lli.localFiles = lli.localFiles.filter { localFile ->
var file = File(localFile.absolutePath)
val file = File(localFile.absolutePath)
if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
hasUpates = true
@@ -143,7 +143,7 @@ class DbManager {
// Check audio tracks and episodes
if (lli.isPodcast) {
var podcast = lli.media as Podcast
val podcast = lli.media as Podcast
podcast.episodes = podcast.episodes?.filter { ep ->
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
@@ -152,7 +152,7 @@ class DbManager {
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
} as MutableList<PodcastEpisode>
} else {
var book = lli.media as Book
val book = lli.media as Book
book.tracks = book.tracks?.filter { track ->
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
@@ -164,7 +164,7 @@ class DbManager {
// Check cover still there
lli.coverAbsolutePath?.let {
var coverFile = File(it)
val coverFile = File(it)
if (!coverFile.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
@@ -183,10 +183,10 @@ class DbManager {
// Remove any local media progress where the local media item is not found
fun cleanLocalMediaProgress() {
var localMediaProgress = getAllLocalMediaProgress()
var localLibraryItems = getLocalLibraryItems()
val localMediaProgress = getAllLocalMediaProgress()
val localLibraryItems = getLocalLibraryItems()
localMediaProgress.forEach {
var matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
if (matchingLLI == null) {
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
Paper.book("localMediaProgress").delete(it.id)
@@ -195,8 +195,8 @@ class DbManager {
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing")
Paper.book("localMediaProgress").delete(it.id)
} else {
var podcast = matchingLLI.media as Podcast
var matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
val podcast = matchingLLI.media as Podcast
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
if (matchingLEp == null) {
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
Paper.book("localMediaProgress").delete(it.id)
@@ -212,15 +212,4 @@ class DbManager {
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
return Paper.book("localPlaybackSession").read(playbackSessionId)
}
fun saveObject(db:String, key:String, value:JSONObject) {
Log.d(tag, "Saving Object $key ${value.toString()}")
Paper.book(db).write(key, value)
}
fun loadObject(db:String, key:String):JSONObject? {
var json: JSONObject? = Paper.book(db).read(key)
Log.d(tag, "Loaded Object $key $json")
return json
}
}
@@ -51,8 +51,8 @@ data class LocalLibraryItem(
@JsonIgnore
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
media.setAudioTracks(audioTracks)
localFiles = _localFiles
media.setAudioTracks(audioTracks)
if (coverContentUrl != null) {
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
@@ -66,24 +66,25 @@ data class LocalLibraryItem(
@JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
var localEpisodeId = episode?.id
var sessionId = "play_local_${UUID.randomUUID()}"
val localEpisodeId = episode?.id
val sessionId = "play_local_${UUID.randomUUID()}"
// Get current progress for local media
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
var currentTime = mediaProgress?.currentTime ?: 0.0
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
val currentTime = mediaProgress?.currentTime ?: 0.0
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
var mediaMetadata = media.metadata
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
val mediaMetadata = media.metadata
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
var authorName = mediaMetadata.getAuthorDisplayName()
val authorName = mediaMetadata.getAuthorDisplayName()
if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
}
var dateNow = System.currentTimeMillis()
val dateNow = System.currentTimeMillis()
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
}
@@ -57,6 +57,8 @@ class PlaybackSession(
@get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
@get:JsonIgnore
val localLibraryItemId get() = localLibraryItem?.id ?: ""
@get:JsonIgnore
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@@ -19,6 +19,7 @@ class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
private fun getLocalLibraryItemId(mediaItemId:String):String {
return "local_" + DeviceManager.getBase64Id(mediaItemId)
@@ -36,7 +37,7 @@ class FolderScanner(var ctx: Context) {
}
}
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
@@ -49,7 +50,7 @@ class FolderScanner(var ctx: Context) {
var mediaItemsUpToDate = 0
// Search for files in media item folder
var foldersFound = df.search(false, DocumentFileType.FOLDER)
val foldersFound = df.search(false, DocumentFileType.FOLDER)
// Match folders found with local library items already saved in db
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
@@ -57,7 +58,7 @@ class FolderScanner(var ctx: Context) {
// Remove existing items no longer there
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
if (fileFound == null) {
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
@@ -68,9 +69,9 @@ class FolderScanner(var ctx: Context) {
foldersFound.forEach { itemFolder ->
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
var existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
var result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
val result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
@@ -90,25 +91,23 @@ class FolderScanner(var ctx: Context) {
}
fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
var itemFolderName = itemFolder.name ?: ""
var itemId = getLocalLibraryItemId(itemFolder.id)
val itemFolderName = itemFolder.name ?: ""
val itemId = getLocalLibraryItemId(itemFolder.id)
var existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
var isNewOrUpdated = existingItem == null
var audioTracks = mutableListOf<AudioTrack>()
var localFiles = mutableListOf<LocalFile>()
val audioTracks = mutableListOf<AudioTrack>()
val localFiles = mutableListOf<LocalFile>()
var index = 1
var startOffset = 0.0
var coverContentUrl:String? = null
var coverAbsolutePath:String? = null
// itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio"))
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
var 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
}
if (existingLocalFilesRemoved.isNotEmpty()) {
@@ -117,14 +116,14 @@ class FolderScanner(var ctx: Context) {
}
filesInFolder.forEach { file ->
var mimeType = file.mimeType ?: ""
var filename = file.name ?: ""
var isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
val mimeType = file.mimeType ?: ""
val filename = file.name ?: ""
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
var localFileId = DeviceManager.getBase64Id(file.id)
val localFileId = DeviceManager.getBase64Id(file.id)
var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
localFiles.add(localFile)
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
@@ -132,7 +131,7 @@ class FolderScanner(var ctx: Context) {
if (isAudio) {
var audioTrackToAdd:AudioTrack? = null
var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
if (existingAudioTrack != null) { // Update existing audio track
if (existingAudioTrack.index != index) {
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
@@ -150,7 +149,7 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
// TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath)
val audioProbeResult = probeAudioFile(localFile.absolutePath)
if (existingAudioTrack != null) {
// Update audio probe data on existing audio track
@@ -172,7 +171,7 @@ class FolderScanner(var ctx: Context) {
index++
audioTracks.add(audioTrackToAdd)
} else {
var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
if (existingLocalFile == null) {
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
@@ -209,8 +208,8 @@ class FolderScanner(var ctx: Context) {
return ItemScanResult.UPDATED
} else if (audioTracks.isNotEmpty()) {
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
var localLibraryItem = localMediaItem.getLocalLibraryItem()
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
val localLibraryItem = localMediaItem.getLocalLibraryItem()
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return ItemScanResult.ADDED
} else {
@@ -219,9 +218,9 @@ class FolderScanner(var ctx: Context) {
}
// Scan item after download and create local library item
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? {
var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
var foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):DownloadItemScanResult? {
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = ""
var itemFolderUrl = ""
@@ -240,20 +239,21 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "scanDownloadItem failed to find media folder")
return null
}
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return null
}
var localLibraryItemId = getLocalLibraryItemId(itemFolderId)
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// 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"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem? = null
if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
@@ -266,10 +266,10 @@ class FolderScanner(var ctx: Context) {
}
}
var audioTracks:MutableList<AudioTrack> = mutableListOf()
val audioTracks:MutableList<AudioTrack> = mutableListOf()
filesFound.forEach { docFile ->
var itemPart = downloadItem.downloadItemParts.find { itemPart ->
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
itemPart.filename == docFile.name
}
if (itemPart == null) {
@@ -277,31 +277,32 @@ class FolderScanner(var ctx: Context) {
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
}
} else if (itemPart.audioTrack != null) { // Is audio track
var audioTrackFromServer = itemPart.audioTrack
val audioTrackFromServer = itemPart.audioTrack
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}")
var localFileId = DeviceManager.getBase64Id(docFile.id)
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile)
// TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath)
val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track
var 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 ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
var podcast = localLibraryItem.media as Podcast
podcast.addEpisode(track, podcastEpisode)
val podcast = localLibraryItem.media as Podcast
var newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
}
} else { // Cover image
var localFileId = DeviceManager.getBase64Id(docFile.id)
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
@@ -332,9 +333,36 @@ class FolderScanner(var ctx: Context) {
localLibraryItem.media.setAudioTracks(audioTracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = false,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId)
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return localLibraryItem
return downloadItemScanResult
}
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
@@ -5,6 +5,9 @@ import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.MediaControllerCompat
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.toRawFile
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -60,10 +63,25 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
// Block on downloading artwork.
val context = playerNotificationService.getContext()
// Fix attempt for #35 local cover crashing
// Convert content uri to a file and pass to Glide
var urival:Any = uri
if (uri.toString().startsWith("content:")) {
val imageDocFile = DocumentFile.fromSingleUri(context, uri)
Log.d(tag, "Converting local content url $uri to file with path ${imageDocFile?.getAbsolutePath(context)}")
val file = imageDocFile?.toRawFile(context)
file?.let {
Log.d(tag, "Using local file image instead of content uri ${it.absolutePath}")
urival = it
}
}
try {
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
Glide.with(context).applyDefaultRequestOptions(glideOptions)
.asBitmap()
.load(uri)
.load(urival)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
@@ -71,7 +89,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
} catch (e: Exception) {
e.printStackTrace()
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
Glide.with(context).applyDefaultRequestOptions(glideOptions)
.asBitmap()
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
@@ -66,19 +66,26 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}")
if (player.isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0) {
if (onSeekBack) onSeekBack = false
else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var backTime = calcPauseSeekBackTime()
if (backTime > 0) {
if (backTime >= playerNotificationService.mPlayer.currentPosition) backTime = playerNotificationService.mPlayer.currentPosition - 500
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500
Log.d(tag, "SeekBackTime $backTime")
onSeekBack = true
playerNotificationService.seekBackward(backTime)
} else {
Log.d(tag, "SeekBackTime: back time is 0")
}
}
}
} else lastPauseTime = System.currentTimeMillis()
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
@@ -96,12 +103,10 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long
if (time < 60000) seekback = 0
else if (time < 120000) seekback = 10000
else if (time < 300000) seekback = 15000
else if (time < 1800000) seekback = 20000
else if (time < 3600000) seekback = 25000
else seekback = 29500
if (time < 3000) seekback = 0
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 seekback = 29500 // 30m and up = jump back 30s
return seekback
}
}
@@ -454,7 +454,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
fun getDuration() : Long {
return currentPlayer.duration
return currentPlaybackSession?.totalDurationMs ?: 0L
}
fun getCurrentBookTitle() : String? {
@@ -509,11 +509,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
fun seekForward(amount: Long) {
currentPlayer.seekTo(currentPlayer.currentPosition + amount)
seekPlayer(getCurrentTime() + amount)
// currentPlayer.seekTo(currentPlayer.currentPosition + amount)
}
fun seekBackward(amount: Long) {
currentPlayer.seekTo(currentPlayer.currentPosition - amount)
seekPlayer(getCurrentTime() - amount)
// currentPlayer.seekTo(currentPlayer.currentPosition - amount)
}
fun setPlaybackSpeed(speed: Float) {
@@ -537,6 +539,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
}
fun getContext():Context {
return ctx
}
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
@@ -1,7 +1,7 @@
package com.audiobookshelf.app.player
import android.os.Handler
import android.os.Looper
import android.content.Context
import android.os.*
import android.util.Log
import java.util.*
import kotlin.concurrent.schedule
@@ -9,9 +9,8 @@ import kotlin.math.roundToInt
const val SLEEP_EXTENSION_TIME = 900000L // 15m
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) {
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
private val tag = "SleepTimerManager"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private var sleepTimerTask:TimerTask? = null
private var sleepTimerRunning:Boolean = false
@@ -64,7 +63,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
// Register shake sensor
playerNotificationService.registerSensor()
var currentTime = getCurrentTime()
val currentTime = getCurrentTime()
if (isChapterTime) {
if (currentTime > time) {
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
@@ -95,7 +94,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (getIsPlaying()) {
sleepTimerElapsed += 1000L
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
if (sleepTimeSecondsRemaining > 0) {
@@ -111,7 +110,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) {
// Start fading out audio
var volume = sleepTimeSecondsRemaining / 30F
val volume = sleepTimeSecondsRemaining / 30F
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume)
}
@@ -129,7 +128,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
playerNotificationService.unregisterSensor()
}
fun getSleepTimerTime():Long? {
fun getSleepTimerTime():Long {
return sleepTimerEndTime
}
@@ -139,6 +138,30 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
}
// Vibrate when extending sleep timer by shaking
private fun vibrate() {
val context = playerNotificationService.getContext()
val vibrator:Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibrator = vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
vibrator.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
it.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
it.vibrate(10)
}
}
}
private fun extendSleepTime() {
if (!sleepTimerRunning) return
setVolume(1F)
@@ -157,7 +180,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (!sleepTimerRunning) {
if (sleepTimerFinishedAt <= 0L) return
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
{
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
@@ -165,14 +188,18 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
return
}
var newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
vibrate()
setSleepTimer(newSleepTime, false)
play()
return
}
// Only extend if within 30 seconds of finishing
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
if (sleepTimeRemaining <= 30) extendSleepTime()
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
if (sleepTimeRemaining <= 30) {
vibrate()
extendSleepTime()
}
}
fun handleShake() {
@@ -188,7 +215,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerLength += time
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else {
var newSleepEndTime = sleepTimerEndTime + time
val newSleepEndTime = sleepTimerEndTime + time
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
getDuration()
} else {
@@ -209,7 +236,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerLength -= time
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
} else {
var newSleepEndTime = sleepTimerEndTime - time
val newSleepEndTime = sleepTimerEndTime - time
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
// End sleep timer in 1 second
getCurrentTime() + 1000
@@ -16,6 +16,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import com.google.android.gms.cast.CastDevice
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import org.json.JSONObject
@CapacitorPlugin(name = "AbsAudioPlayer")
@@ -25,7 +27,7 @@ class AbsAudioPlayer : Plugin() {
private lateinit var mainActivity: MainActivity
private lateinit var apiHandler:ApiHandler
lateinit var castManager:CastManager
var castManager:CastManager? = null
lateinit var playerNotificationService: PlayerNotificationService
@@ -95,6 +97,24 @@ class AbsAudioPlayer : Plugin() {
}
private fun initCastManager() {
val googleApi = GoogleApiAvailability.getInstance()
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
if (statusCode != ConnectionResult.SUCCESS) {
if (statusCode == ConnectionResult.SERVICE_MISSING) {
Log.w(tag, "initCastManager: Google Api Missing")
} else if (statusCode == ConnectionResult.SERVICE_DISABLED) {
Log.w(tag, "initCastManager: Google Api Disabled")
} else if (statusCode == ConnectionResult.SERVICE_INVALID) {
Log.w(tag, "initCastManager: Google Api Invalid")
} else if (statusCode == ConnectionResult.SERVICE_UPDATING) {
Log.w(tag, "initCastManager: Google Api Updating")
} else if (statusCode == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) {
Log.w(tag, "initCastManager: Google Api Update Required")
}
return
}
val connListener = object: CastManager.ChromecastListener() {
override fun onReceiverAvailableUpdate(available: Boolean) {
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
@@ -128,7 +148,7 @@ class AbsAudioPlayer : Plugin() {
}
castManager = CastManager(mainActivity)
castManager.startRouteScan(connListener)
castManager?.startRouteScan(connListener)
}
@PluginMethod
@@ -144,7 +164,7 @@ class AbsAudioPlayer : Plugin() {
val libraryItemId = call.getString("libraryItemId", "").toString()
val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true
var playbackRate = call.getFloat("playbackRate",1f) ?: 1f
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
@@ -322,7 +342,11 @@ class AbsAudioPlayer : Plugin() {
// Need to make sure the player service has been started
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
call.resolve()
castManager.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
if (castManager == null) {
Log.e(tag, "Cast Manager not initialized")
return
}
castManager?.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
}
@@ -206,45 +206,93 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
var isFinished = call.getBoolean("isFinished", false) == true
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
val localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val isFinished = call.getBoolean("isFinished", false) == true
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.e(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId")
call.resolve(JSObject("{\"error\":\"Progress not found\"}"))
if (localMediaProgress == null) { // Create new local media progress if does not exist
Log.d(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId - Creating new")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
}
if (localLibraryItem.mediaType != "podcast" && !localEpisodeId.isNullOrEmpty()) {
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
}
var duration = 0.0
var podcastEpisode:PodcastEpisode? = null
if (!localEpisodeId.isNullOrEmpty()) {
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { episode ->
episode.id == localEpisodeId
}
if (podcastEpisode == null) {
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
}
duration = podcastEpisode.duration ?: 0.0
} else {
val book = localLibraryItem.media as Book
duration = book.duration ?: 0.0
}
val currentTime = System.currentTimeMillis()
localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = duration,
progress = if (isFinished) 1.0 else 0.0,
currentTime = 0.0,
isFinished = isFinished,
lastUpdate = currentTime,
startedAt = if (isFinished) currentTime else 0L,
finishedAt = if (isFinished) currentTime else null,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = podcastEpisode?.serverEpisodeId)
} else {
localMediaProgress.updateIsFinished(isFinished)
}
var lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
// Save local media progress locally
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
// Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject()
updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
// call.resolve(JSObject("{\"local\":true,\"server\":true,\"localMediaProgress\":$lmpstring}"))
}
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
// Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject()
updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
}
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
// call.resolve(JSObject("{\"local\":true,\"localMediaProgress\":$lmpstring}}"))
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
}
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
}
@@ -288,38 +336,4 @@ class AbsDatabase : Plugin() {
call.resolve()
}
}
//
// Generic Webview calls to db
//
@PluginMethod
fun saveFromWebview(call: PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
var value = call.getObject("value")
GlobalScope.launch(Dispatchers.IO) {
if (db == "" || key == "" || value == null) {
Log.d(tag, "saveFromWebview Invalid key/value")
} else {
var json = value as JSONObject
DeviceManager.dbManager.saveObject(db, key, json)
}
call.resolve()
}
}
@PluginMethod
fun loadFromWebview(call:PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
if (db == "" || key == "") {
Log.d(tag, "loadFromWebview Invalid Key")
call.resolve()
return
}
var json = DeviceManager.dbManager.loadObject(db, key)
var jsobj = JSObject.fromJSONObject(json)
call.resolve(jsobj)
}
}
@@ -63,7 +63,10 @@ class AbsDownloader : Plugin() {
fun make(filename:String, destinationFile:File, finalDestinationFile:File, itemTitle:String, serverPath:String, localFolder:LocalFolder, audioTrack:AudioTrack?, episode:PodcastEpisode?) :DownloadItemPart {
val destinationUri = Uri.fromFile(destinationFile)
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
val downloadUri = Uri.parse(downloadUrl)
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
return DownloadItemPart(
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
@@ -102,6 +105,7 @@ class AbsDownloader : Plugin() {
val id: String,
val libraryItemId:String,
val episodeId:String?,
val userMediaProgress:MediaProgress?,
val serverConnectionConfigId:String,
val serverAddress:String,
val serverUserId:String,
@@ -138,7 +142,7 @@ class AbsDownloader : Plugin() {
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
}
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
@@ -176,19 +180,34 @@ class AbsDownloader : Plugin() {
// Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String {
val cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
cleanedRelPath = cleanStringForFileSystem(cleanedRelPath)
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Replace characters that cant be used in the file system
// Reserved characters: ?:\"*|/\\<>
private fun cleanStringForFileSystem(str:String):String {
val reservedCharacters = listOf("?", "\"", "*", "|", "/", "\\", "<", ">")
var newTitle = str
newTitle = newTitle.replace(":", " -") // Special case replace : with -
reservedCharacters.forEach {
newTitle = newTitle.replace(it, "")
}
return newTitle
}
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
if (libraryItem.mediaType == "book") {
val bookTitle = libraryItem.media.metadata.title
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
// Create download item part for each audio track
tracks.forEach { audioTrack ->
@@ -215,7 +234,7 @@ class AbsDownloader : Plugin() {
if (downloadItem.downloadItemParts.isNotEmpty()) {
// Add cover download item
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
val serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg"
val serverPath = "/api/items/${libraryItem.id}/cover"
val destinationFilename = "cover.jpg"
val destinationFile = File("$tempFolderPath/$destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
@@ -239,13 +258,13 @@ class AbsDownloader : Plugin() {
}
} else {
// Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val podcastTitle = libraryItem.media.metadata.title
val audioTrack = episode?.audioTrack
Log.d(tag, "Starting podcast episode download")
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
val downloadItemId = "${libraryItem.id}-${episode?.id}"
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf())
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
@@ -266,7 +285,7 @@ class AbsDownloader : Plugin() {
downloadItemPart.downloadId = downloadId
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg"
serverPath = "/api/items/${libraryItem.id}/cover"
destinationFilename = "cover.jpg"
destinationFile = File("$tempFolderPath/$destinationFilename")
@@ -290,7 +309,7 @@ class AbsDownloader : Plugin() {
}
}
fun startWatchingDownloads(downloadItem: DownloadItem) {
private fun startWatchingDownloads(downloadItem: DownloadItem) {
GlobalScope.launch(Dispatchers.IO) {
while (downloadItem.downloadItemParts.find { !it.moved && !it.failed } != null) { // While some item is not completed
val numPartsBefore = downloadItem.downloadItemParts.size
@@ -306,23 +325,34 @@ class AbsDownloader : Plugin() {
delay(500)
}
val localLibraryItem = folderScanner.scanDownloadItem(downloadItem)
// Remove download notifications
downloadItem.downloadItemParts.forEach { downloadItemPart ->
downloadItemPart.downloadId?.let {
downloadManager.remove(it)
}
}
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
downloadQueue.remove(downloadItem)
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
val jsobj = JSObject()
jsobj.put("libraryItemId", downloadItem.id)
jsobj.put("localFolderId", downloadItem.localFolder.id)
if (localLibraryItem != null) {
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
}
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
notifyListeners("onItemDownloadComplete", jsobj)
}
}
fun checkDownloads(downloadItem: DownloadItem) {
private fun checkDownloads(downloadItem: DownloadItem) {
val itemParts = downloadItem.downloadItemParts.map { it }
for (downloadItemPart in itemParts) {
if (downloadItemPart.downloadId != null) {
@@ -344,6 +374,7 @@ class AbsDownloader : Plugin() {
if (!downloadItemPart.completed) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
downloadItemPart.completed = true
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}")
@@ -88,13 +88,13 @@ class ApiHandler(var ctx:Context) {
response.use {
if (!it.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = it.body!!.string()
val bodyString = it.body!!.string()
if (bodyString == "OK") {
cb(JSObject())
} else {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
var array = JSArray(bodyString)
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
@@ -111,7 +111,7 @@ class ApiHandler(var ctx:Context) {
getRequest("/api/libraries") {
val libraries = mutableListOf<Library>()
if (it.has("value")) {
var array = it.getJSONArray("value")
val array = it.getJSONArray("value")
for (i in 0 until array.length()) {
val library = mapper.readValue<Library>(array.get(i).toString())
libraries.add(library)
@@ -128,11 +128,20 @@ class ApiHandler(var ctx:Context) {
}
}
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") {
val items = mutableListOf<LibraryItem>()
if (it.has("results")) {
var array = it.getJSONArray("results")
val array = it.getJSONArray("results")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
items.add(item)
@@ -146,11 +155,11 @@ class ApiHandler(var ctx:Context) {
getRequest("/api/libraries/$libraryId/personalized") {
val items = mutableListOf<LibraryCategory>()
if (it.has("value")) {
var array = it.getJSONArray("value")
val array = it.getJSONArray("value")
for (i in 0 until array.length()) {
var jsobj = array.get(i) as JSONObject
val jsobj = array.get(i) as JSONObject
var type = jsobj.get("type").toString()
val type = jsobj.get("type").toString()
// Only support for podcast and book in android auto
if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false)
@@ -164,7 +173,7 @@ class ApiHandler(var ctx:Context) {
}
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
var payload = JSObject()
val payload = JSObject()
payload.put("mediaPlayer", mediaPlayer)
// Only if direct play fails do we force transcode
@@ -181,7 +190,7 @@ class ApiHandler(var ctx:Context) {
}
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
var payload = JSObject(jacksonMapper.writeValueAsString(syncData))
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) {
cb()
@@ -189,7 +198,7 @@ class ApiHandler(var ctx:Context) {
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
var payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload) {
cb()
@@ -204,15 +213,15 @@ class ApiHandler(var ctx:Context) {
}
// Get all local media progress connected to items on the current connected server
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
}
var localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
if (localMediaProgress.isNotEmpty()) {
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
var payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
postRequest("/api/me/sync-local-progress", payload) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
@@ -242,7 +251,7 @@ class ApiHandler(var ctx:Context) {
fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) {
Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload")
var endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
patchRequest(endpoint,updatePayload) {
Log.d(tag, "updateMediaProgress patched progress")
cb()
+275 -40
View File
@@ -1,10 +1,10 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/fonts/MaterialIcons.woff2) format('woff2');
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
@@ -12,43 +12,6 @@
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
}
/* .material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
line-height: 1;
font-size: 1.5rem;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons.text-icon {
font-size: 1.15rem;
}
.material-icons.text-lg {
font-size: 1.25rem;
}
.material-icons.text-2xl {
font-size: 1.5rem;
}
.material-icons.text-3xl {
font-size: 1.875rem;
}
.material-icons.text-4xl {
font-size: 2.25rem;
}
.material-icons.text-5xl {
font-size: 3rem;
}
.material-icons.text-base {
font-size: 1rem;
} */
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
@@ -60,9 +23,9 @@
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem;
}
@@ -78,9 +41,9 @@
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem;
}
@@ -93,6 +56,7 @@
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
@@ -101,4 +65,275 @@
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* latin-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+3 -5
View File
@@ -18,8 +18,8 @@
<widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer">
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.75rem" @click="castClick">cast</span>
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span>
</div>
<nuxt-link v-if="user" class="h-7 mx-2" to="/search">
@@ -80,11 +80,9 @@ export default {
methods: {
castClick() {
if (this.$store.state.playerIsLocal) {
this.$toast.warn('Cannot cast downloaded media item')
this.$eventBus.$emit('cast-local-item')
return
}
console.log('Cast Btn Click')
AbsAudioPlayer.requestSession()
},
clickShowSideDrawer() {
+13 -6
View File
@@ -4,7 +4,7 @@
<div class="top-2 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
</div>
<div v-show="showCastBtn" class="top-3.5 right-20 absolute cursor-pointer">
<div v-show="showCastBtn" class="top-4 right-16 absolute cursor-pointer">
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div>
<div class="top-4 right-4 absolute cursor-pointer">
@@ -12,6 +12,7 @@
<span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu>
</div>
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
</div>
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
@@ -63,7 +64,7 @@
<div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<div class="play-btn cursor-pointer shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
@@ -159,7 +160,7 @@ export default {
return this.showFullscreen ? 200 : 60
},
showCastBtn() {
return this.$store.state.isCastAvailable && !this.isLocalPlayMethod
return this.$store.state.isCastAvailable
},
isCasting() {
return this.mediaPlayer === 'cast-player'
@@ -193,6 +194,9 @@ export default {
isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL
},
isDirectPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
},
title() {
if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
@@ -269,12 +273,10 @@ export default {
this.showChapterModal = false
},
castClick() {
console.log('Cast Btn Click')
if (this.isLocalPlayMethod) {
this.$toast.warn('Cannot cast downloaded media items')
this.$eventBus.$emit('cast-local-item')
return
}
AbsAudioPlayer.requestSession()
},
clickContainer() {
@@ -634,6 +636,11 @@ export default {
this.$nextTick(this.init)
},
beforeDestroy() {
if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback')
this.closePlayback()
}
this.forceCloseDropdownMenu()
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
+30
View File
@@ -168,10 +168,36 @@ export default {
this.$refs.audioPlayer.closePlayback()
}
},
castLocalItem() {
if (!this.serverLibraryItemId) {
this.$toast.error(`Cannot cast locally downloaded media`)
} else {
// Change to server library item
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
}
},
playServerLibraryItemAndCast(libraryItemId) {
var playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
})
.catch((error) => {
console.error('Failed', error)
})
},
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId
// When playing local library item and can also play this item from the server
// then store the server library item id so it can be used if a cast is made
var serverLibraryItemId = payload.serverLibraryItemId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({
title: 'Warning',
@@ -195,6 +221,8 @@ export default {
console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
} else {
this.serverLibraryItemId = serverLibraryItemId
}
})
.catch((error) => {
@@ -228,6 +256,7 @@ export default {
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
},
beforeDestroy() {
@@ -246,6 +275,7 @@ export default {
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$store.commit('user/removeSettingsListener', 'streamContainer')
}
}
+5
View File
@@ -108,6 +108,11 @@ export default {
text: 'Local Media',
to: '/localMedia/folders'
})
// items.push({
// icon: 'settings',
// text: 'Settings',
// to: '/settings'
// })
}
return items
},
+1
View File
@@ -3,6 +3,7 @@
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div v-else class="flex border-t border-white border-opacity-10 my-3 py-1"/>
</div>
</template>
+1
View File
@@ -3,6 +3,7 @@
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<template v-for="(entity, index) in entities">
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
</template>
+57 -35
View File
@@ -34,8 +34,13 @@
</div>
</div>
<!-- Play/pause button for podcast episode -->
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70">
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
</div>
<!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && !isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
@@ -46,13 +51,18 @@
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
<!-- Volume number -->
<!-- Series sequence -->
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
</div>
<!-- Podcast Num Episodes -->
<div v-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<div v-else-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
</div>
@@ -196,6 +206,17 @@ export default {
seriesSequence() {
return this.series ? this.series.sequence : null
},
recentEpisode() {
// Only added to item when getting currently listening podcasts
return this._libraryItem.recentEpisode
},
recentEpisodeNumber() {
if (!this.recentEpisode) return null
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return this.recentEpisode.index
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
@@ -222,7 +243,14 @@ export default {
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null
},
episodeProgress() {
// Only used on home page currently listening podcast shelf
if (!this.recentEpisode) return null
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.recentEpisode.id)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
if (this.episodeProgress) return this.episodeProgress
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@@ -233,19 +261,28 @@ export default {
return this.userProgress ? !!this.userProgress.isFinished : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
return this.numMissingParts || this.isMissing || this.isInvalid
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
localLibraryItemId() {
if (this.isLocal) return this.libraryItemId
return this.localLibraryItem ? this.localLibraryItem.id : null
},
isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
if (this.isPodcast) {
if (this.playerIsLocal) {
// Check is streaming local version of this episode
return false // episode cards not implemented for local yet
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
} else {
return false // not yet necessary for books
}
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
},
isMissing() {
return this._libraryItem.isMissing
@@ -253,24 +290,9 @@ export default {
isInvalid() {
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._libraryItem.hasMissingParts
},
hasInvalidParts() {
return this._libraryItem.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no media files'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
numMissingParts() {
if (this.isPodcast) return 0
return this.media.numMissingParts
},
overlayWrapperClasslist() {
var classes = []
@@ -343,6 +365,10 @@ export default {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
} else if (this.recentEpisode) {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) eventBus.$emit('pause-item')
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
} else {
var router = this.$router || this.$nuxt.$router
if (router) {
@@ -420,10 +446,6 @@ export default {
this.selected = !this.selected
this.$emit('select', this.libraryItem)
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
+16
View File
@@ -20,6 +20,8 @@
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
</div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
@@ -99,9 +101,23 @@ export default {
mediaType() {
return this._libraryItem.mediaType
},
duration() {
return this.media.duration || null
},
isPodcast() {
return this.mediaType === 'podcast'
},
episodes() {
if (this.isPodcast) {
if (this.media.numEpisodes==1) {
return "1 episode"
} else {
return this.media.numEpisodes + ' episodes'
}
} else {
return null
}
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="absolute cover-bg" ref="coverBg" />
</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">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
+9 -6
View File
@@ -27,9 +27,11 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons px-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
<div v-if="!isIos">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div>
</div>
</div>
@@ -61,6 +63,9 @@ export default {
}
},
computed: {
isIos() {
return this.$platform === 'ios'
},
mediaType() {
return 'podcast'
},
@@ -204,9 +209,7 @@ export default {
var isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var localMediaProgressId = `${localLibraryItemId}-${localEpisodeId}`
console.log('toggleFinished local media progress id', localMediaProgressId, isFinished)
var payload = await this.$db.updateLocalMediaProgressFinished({ localMediaProgressId, isFinished })
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) {
var errorMsg = payload ? payload.error : 'Unknown error'
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<button class="icon-btn rounded-md flex items-center justify-center px-2 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
+48
View File
@@ -0,0 +1,48 @@
<template>
<div>
<div class="border rounded-full border-gray-400 flex items-center cursor-pointer w-10 justify-start" :class="className" @click.stop="clickToggle">
<span class="rounded-full border w-5 h-5 border-gray-100 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
onColor: {
type: String,
default: 'success'
},
offColor: {
type: String,
default: 'primary'
},
disabled: Boolean
},
computed: {
toggleValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
className() {
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
}
},
methods: {
clickToggle() {
if (this.disabled) return
this.toggleValue = !this.toggleValue
}
}
}
</script>
@@ -97,6 +97,11 @@ export default {
this.$eventBus.$emit('new-local-library-item', data.localLibraryItem)
}
if (data.localMediaProgress) {
console.log('onItemDownloadComplete updating local media progress', data.localMediaProgress.id)
this.$store.commit('globals/updateLocalMediaProgress', data.localMediaProgress)
}
this.$store.commit('globals/removeItemDownload', data.libraryItemId)
}
},
+10 -6
View File
@@ -30,6 +30,7 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -60,6 +61,7 @@
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -118,6 +120,7 @@
children = (
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
);
path = models;
@@ -309,6 +312,7 @@
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -455,14 +459,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.42;
MARKETING_VERSION = 0.9.44;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.development;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OBJC_BRIDGING_HEADER = "App/App-Bridging-Header.h";
@@ -479,12 +483,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.42;
MARKETING_VERSION = 0.9.44;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+7 -1
View File
@@ -14,13 +14,19 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seek, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seekForward, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(seekBackward, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getCurrentTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(cancelSleepTimer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(decreaseSleepTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(increaseSleepTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getSleepTimerTime, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setSleepTimer, CAPPluginReturnPromise);
)
+104 -38
View File
@@ -10,12 +10,19 @@ import Capacitor
@objc(AbsAudioPlayer)
public class AbsAudioPlayer: CAPPlugin {
private var initialPlayWhenReady = false
private var initialPlaybackRate:Float = 1
override public func load() {
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendPlaybackClosedEvent), name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: UIApplication.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
}
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
@@ -33,8 +40,11 @@ public class AbsAudioPlayer: CAPPlugin {
return call.resolve()
}
initialPlayWhenReady = playWhenReady
initialPlaybackRate = playbackRate
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId) { session in
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
do {
@@ -43,7 +53,6 @@ public class AbsAudioPlayer: CAPPlugin {
} catch(let exception) {
NSLog("failed to convert session to json")
debugPrint(exception)
call.resolve([:])
}
@@ -68,18 +77,19 @@ public class AbsAudioPlayer: CAPPlugin {
call.resolve()
}
@objc func playPause(_ call: CAPPluginCall) {
PlayerHandler.playPause()
call.resolve([ "playing": !PlayerHandler.paused() ])
}
@objc func playPlayer(_ call: CAPPluginCall) {
PlayerHandler.play()
PlayerHandler.paused = false
call.resolve()
}
@objc func pausePlayer(_ call: CAPPluginCall) {
PlayerHandler.pause()
PlayerHandler.paused = true
call.resolve()
}
// I have no clue why but after i moved this block of code from above "playPlayer" to here the app stopped crashing. Move it back up if you want to
@objc func playPause(_ call: CAPPluginCall) {
PlayerHandler.paused = !PlayerHandler.paused
call.resolve([ "playing": !PlayerHandler.paused ])
}
@objc func seek(_ call: CAPPluginCall) {
PlayerHandler.seek(amount: call.getDouble("value", 0.0))
@@ -95,13 +105,97 @@ public class AbsAudioPlayer: CAPPlugin {
}
@objc func sendMetadata() {
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused() ])
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
}
@objc func sendPlaybackClosedEvent() {
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
}
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000)
call.resolve()
}
@objc func increaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000)
call.resolve()
}
@objc func setSleepTimer(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
NSLog("chapter time: \(call.getBool("isChapterTime", false))")
if call.getBool("isChapterTime", false) {
let timeToPause = time / 1000 - Int(PlayerHandler.getCurrentTime() ?? 0)
if timeToPause < 0 { return call.resolve([ "success": false ]) }
NSLog("oof \(timeToPause)")
PlayerHandler.remainingSleepTime = timeToPause
return call.resolve([ "success": true ])
}
PlayerHandler.remainingSleepTime = time / 1000
call.resolve([ "success": true ])
}
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
PlayerHandler.remainingSleepTime = nil
call.resolve()
}
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
call.resolve([
"value": PlayerHandler.remainingSleepTime
])
}
@objc func sendSleepTimerEnded() {
self.notifyListeners("onSleepTimerEnded", data: [
"value": PlayerHandler.getCurrentTime()
])
}
@objc func sendSleepTimerSet() {
self.notifyListeners("onSleepTimerSet", data: [
"value": PlayerHandler.remainingSleepTime
])
}
@objc func onPlaybackFailed() {
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
let playbackSession = PlayerHandler.getPlaybackSession()
let libraryItemId = playbackSession?.libraryItemId ?? ""
let episodeId = playbackSession?.episodeId ?? nil
NSLog("TEST: Forcing Transcode")
// If direct playing then fallback to transcode
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
do {
self.sendPlaybackSession(session: try session.asDictionary())
} catch(let exception) {
NSLog("failed to convert session to json")
debugPrint(exception)
}
self.sendMetadata()
}
} else {
self.notifyListeners("onPlaybackFailed", data: [
"value": "Playback Error"
])
}
}
@objc func sendPrepareMetadataEvent(itemId: String, playWhenReady: Bool) {
self.notifyListeners("onPrepareMedia", data: [
"audiobookId": itemId,
@@ -111,32 +205,4 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func sendPlaybackSession(session: [String: Any]) {
self.notifyListeners("onPlaybackSession", data: session)
}
/*
IMPLEMENTED:
cancelSleepTimer
decreaseSleepTime
increaseSleepTime
getSleepTimerTime
setSleepTimer
* closePlayback
* setPlaybackSpeed
* seekBackward
* seekForward
* seek
* playPause
* playPlayer
* pausePlayer
* getCurrentTime
* onPlaybackSession
* onPrepareMedia
* onPlaybackClosed
* onPlayingUpdate
* onMetadata
onSleepTimerEnded
onSleepTimerSet
*/
}
+4 -5
View File
@@ -53,7 +53,7 @@ public class AbsDatabase: CAPPlugin {
}
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
let id = call.getString("serverConnectionConfigId", "")
Database.deleteServerConnectionConfig(id: id)
Database.shared.deleteServerConnectionConfig(id: id)
call.resolve()
}
@@ -63,13 +63,12 @@ public class AbsDatabase: CAPPlugin {
}
@objc func getDeviceData(_ call: CAPPluginCall) {
let configs = Database.getServerConnectionConfigs()
let index = Database.getLastActiveConfigIndex()
let configs = Database.shared.getServerConnectionConfigs()
let index = Database.shared.getLastActiveConfigIndex()
call.resolve([
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id,
// Luckily this isn't implemented yet
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
// "currentLocalPlaybackSession": nil,
])
}
+127
View File
@@ -0,0 +1,127 @@
//
// DataClasses.swift
// App
//
// Created by Benonymity on 4/20/22.
//
import Foundation
import CoreMedia
struct LibraryItem: Codable {
var id: String
var ino:String
var libraryId: String
var folderId: String
var path: String
var relPath: String
var mtimeMs: Int64
var ctimeMs: Int64
var birthtimeMs: Int64
var addedAt: Int64
var updatedAt: Int64
var lastScan: Int64?
var scanVersion: String?
var isMissing: Bool
var isInvalid: Bool
var mediaType: String
var media: MediaType
var libraryFiles: [LibraryFile]
}
struct MediaType: Codable {
var libraryItemId: String?
var metadata: Metadata
var coverPath: String?
var tags: [String]?
var audioFiles: [AudioTrack]?
var chapters: [Chapter]?
var tracks: [AudioTrack]?
var size: Int64?
var duration: Double?
var episodes: [PodcastEpisode]?
var autoDownloadEpisodes: Bool?
}
struct Metadata: Codable {
var title: String
var subtitle: String?
var authors: [Author]?
var narrators: [String]?
var genres: [String]
var publishedYear: String?
var publishedDate: String?
var publisher: String?
var description: String?
var isbn: String?
var asin: String?
var language: String?
var explicit: Bool
var authorName: String?
var authorNameLF: String?
var narratorName: String?
var seriesName: String?
var feedUrl: String?
}
struct PodcastEpisode: Codable {
var id: String
var index: Int
var episode: String?
var episodeType: String?
var title: String
var subtitle: String?
var description: String?
var audioFile: AudioFile?
var audioTrack: AudioTrack?
var duration: Double
var size: Int64
// var serverEpisodeId: String?
}
struct AudioFile: Codable {
var index: Int
var ino: String
var metadata: FileMetadata
}
struct Author: Codable {
var id: String
var name: String
var coverPath: String?
}
struct Chapter: Codable {
var id: Int
var start: Double
var end: Double
var title: String?
}
struct AudioTrack: Codable {
var index: Int?
var startOffset: Double?
var duration: Double
var title: String?
var contentUrl: String?
var mimeType: String
var metadata: FileMetadata?
// var isLocal: Bool
// var localFileId: String?
// var audioProbeResult: AudioProbeResult? Needed for local playback
var serverIndex: Int?
}
struct FileMetadata: Codable {
var filename: String
var ext: String
var path: String
var relPath: String
}
struct Library: Codable {
var id: String
var name: String
var folders: [Folder]
var icon: String
var mediaType: String
}
struct Folder: Codable {
var id: String
var fullPath: String
}
struct LibraryFile: Codable {
var ino: String
var metadata: FileMetadata
}
+2 -27
View File
@@ -6,7 +6,7 @@
//
import Foundation
struct PlaybackSession: Decodable, Encodable {
var id: String
var userId: String?
@@ -25,33 +25,8 @@ struct PlaybackSession: Decodable, Encodable {
var timeListening: Double
var audioTracks: [AudioTrack]
var currentTime: Double
// var libraryItem: LibraryItem?
var libraryItem: LibraryItem
// var localLibraryItem: LocalLibraryItem?
var serverConnectionConfigId: String?
var serverAddress: String?
}
struct Chapter: Decodable, Encodable {
var id: Int
var start: Double
var end: Double
var title: String?
}
struct AudioTrack: Decodable, Encodable {
var index: Int?
var startOffset: Double
var duration: Double
var title: String
var contentUrl: String
var mimeType: String
var metadata: FileMetadata?
// var isLocal: Bool
// var localFileId: String?
// var audioProbeResult: AudioProbeResult? Needed for local playback
var serverIndex: Int?
}
struct FileMetadata: Decodable, Encodable {
var filename: String
var ext: String
var path: String
var relPath: String
}
+168 -61
View File
@@ -10,6 +10,13 @@ import AVFoundation
import UIKit
import MediaPlayer
enum PlayMethod:Int {
case directplay = 0
case directstream = 1
case transcode = 2
case local = 3
}
class AudioPlayer: NSObject {
// enums and @objc are not compatible
@objc dynamic var status: Int
@@ -24,29 +31,25 @@ class AudioPlayer: NSObject {
private var playWhenReady: Bool
private var initialPlaybackRate: Float
private var audioPlayer: AVPlayer
private var audioPlayer: AVQueuePlayer
private var playbackSession: PlaybackSession
private var activeAudioTrack: AudioTrack
private var queueObserver:NSKeyValueObservation?
private var queueItemStatusObserver:NSKeyValueObservation?
private var currentTrackIndex = 0
private var allPlayerItems:[AVPlayerItem] = []
// MARK: - Constructor
init(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) {
self.playWhenReady = playWhenReady
self.initialPlaybackRate = playbackRate
self.audioPlayer = AVPlayer()
self.audioPlayer = AVQueuePlayer()
self.playbackSession = playbackSession
self.status = -1
self.rate = 0.0
self.tmpRate = playbackRate
if playbackSession.audioTracks.count != 1 || playbackSession.audioTracks[0].mimeType != "application/vnd.apple.mpegurl" {
NSLog("The player only support HLS streams right now")
self.activeAudioTrack = AudioTrack(index: 0, startOffset: -1, duration: -1, title: "", contentUrl: "", mimeType: "")
super.init()
return
}
self.activeAudioTrack = playbackSession.audioTracks[0]
super.init()
initAudioSession()
@@ -56,17 +59,35 @@ class AudioPlayer: NSObject {
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
let playerItem = AVPlayerItem(asset: createAsset())
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: .new, context: &playerItemContext)
for track in playbackSession.audioTracks {
let playerItem = AVPlayerItem(asset: createAsset(itemId: playbackSession.libraryItemId!, track: track))
self.allPlayerItems.append(playerItem)
}
self.audioPlayer.replaceCurrentItem(with: playerItem)
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
NSLog("TEST: Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
NSLog("TEST: Setting player items \(playerItems.count)")
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
setupQueueObserver()
setupQueueItemStatusObserver()
NSLog("Audioplayer ready")
}
deinit {
self.queueObserver?.invalidate()
self.queueItemStatusObserver?.invalidate()
destroy()
}
public func destroy() {
// Pause is not synchronous causing this error on below lines:
// AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session
// It is related to L79 `AVAudioSession.sharedInstance().setActive(false)`
pause()
audioPlayer.replaceCurrentItem(with: nil)
@@ -77,12 +98,60 @@ class AudioPlayer: NSObject {
print(error)
}
// DispatchQueue.main.sync {
DispatchQueue.runOnMainQueue {
UIApplication.shared.endReceivingRemoteControlEvents()
// }
}
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
}
func getItemIndexForTime(time:Double) -> Int {
for index in 0..<self.allPlayerItems.count {
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
let duration = playbackSession.audioTracks[index].duration
let trackEnd = startOffset + duration
if (time < trackEnd.rounded(.down)) {
return index
}
}
return 0
}
func setupQueueObserver() {
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in
let prevTrackIndex = self.currentTrackIndex
self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) {
NSLog("TEST: New Current track index \(self.currentTrackIndex)")
}
}
}
}
func setupQueueItemStatusObserver() {
self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
if (playerItem.status == .readyToPlay) {
NSLog("TEST: queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
self.updateNowPlaying()
let firstReady = self.status < 0
self.status = 0
if self.playWhenReady {
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
self.playWhenReady = false
self.play()
} else if (firstReady) { // Only seek on first readyToPlay
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
}
} else if (playerItem.status == .failed) {
NSLog("TEST: queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
})
}
// MARK: - Methods
public func play(allowSeekBack: Bool = false) {
if allowSeekBack {
@@ -106,11 +175,11 @@ class AudioPlayer: NSObject {
}
if time != nil {
seek(getCurrentTime() - Double(time!))
seek(getCurrentTime() - Double(time!), from: "play")
}
}
lastPlayTime = Date.timeIntervalSinceReferenceDate
self.audioPlayer.play()
self.status = 1
self.rate = self.tmpRate
@@ -118,6 +187,7 @@ class AudioPlayer: NSObject {
updateNowPlaying()
}
public func pause() {
self.audioPlayer.pause()
self.status = 0
@@ -126,24 +196,60 @@ class AudioPlayer: NSObject {
updateNowPlaying()
lastPlayTime = Date.timeIntervalSinceReferenceDate
}
public func seek(_ to: Double) {
let continuePlaing = rate > 0.0
public func seek(_ to: Double, from: String) {
let continuePlaying = rate > 0.0
pause()
self.audioPlayer.seek(to: CMTime(seconds: to, preferredTimescale: 1000)) { completed in
if !completed {
NSLog("WARNING: seeking not completed (to \(to)")
NSLog("TEST: Seek to \(to) from \(from)")
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration
NSLog("TEST: Seek current track END = \(trackEnd)")
let indexOfSeek = getItemIndexForTime(time: to)
NSLog("TEST: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
// Reconstruct queue if seeking to a different track
if (self.currentTrackIndex != indexOfSeek) {
self.currentTrackIndex = indexOfSeek
self.playbackSession.currentTime = to
self.playWhenReady = continuePlaying // Only playWhenReady if already playing
self.status = -1
let playerItems = self.allPlayerItems[indexOfSeek..<self.allPlayerItems.count]
self.audioPlayer.removeAllItems()
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
if continuePlaing {
self.play()
setupQueueItemStatusObserver()
} else {
NSLog("TEST: Seeking in current item \(to)")
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = to - currentTrackStartOffset
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in
if !completed {
NSLog("WARNING: seeking not completed (to \(seekTime)")
}
if continuePlaying {
self.play()
}
self.updateNowPlaying()
}
self.updateNowPlaying()
}
}
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
if self.audioPlayer.rate != rate {
NSLog("TEST: setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
self.audioPlayer.rate = rate
}
if rate > 0.0 && !(observed && rate == 1) {
@@ -151,25 +257,42 @@ class AudioPlayer: NSObject {
}
self.rate = rate
self.updateNowPlaying()
}
public func getCurrentTime() -> Double {
self.audioPlayer.currentTime().seconds
let currentTrackTime = self.audioPlayer.currentTime().seconds
let audioTrack = playbackSession.audioTracks[currentTrackIndex]
let startOffset = audioTrack.startOffset ?? 0.0
return startOffset + currentTrackTime
}
public func getPlayMethod() -> Int {
return self.playbackSession.playMethod
}
public func getPlaybackSession() -> PlaybackSession {
return self.playbackSession
}
public func getDuration() -> Double {
self.audioPlayer.currentItem?.duration.seconds ?? 0
return playbackSession.duration
}
// MARK: - Private
private func createAsset() -> AVAsset {
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(activeAudioTrack.contentUrl)")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset {
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
// The only reason this is separate is because the filename needs to be encoded
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
} else { // HLS Transcode
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
}
}
private func initAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay])
@@ -182,9 +305,9 @@ class AudioPlayer: NSObject {
// MARK: - Now playing
private func setupRemoteTransportControls() {
// DispatchQueue.main.sync {
DispatchQueue.runOnMainQueue {
UIApplication.shared.beginReceivingRemoteControlEvents()
// }
}
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
@@ -205,7 +328,7 @@ class AudioPlayer: NSObject {
return .noSuchContent
}
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue)
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue, from: "remote")
return .success
}
commandCenter.skipBackwardCommand.isEnabled = true
@@ -215,7 +338,7 @@ class AudioPlayer: NSObject {
return .noSuchContent
}
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue)
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue, from: "remote")
return .success
}
@@ -225,7 +348,7 @@ class AudioPlayer: NSObject {
return .noSuchContent
}
self.seek(event.positionTime)
self.seek(event.positionTime, from: "remote")
return .success
}
@@ -242,33 +365,17 @@ class AudioPlayer: NSObject {
}
private func updateNowPlaying() {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NowPlayingInfo.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate)
NowPlayingInfo.shared.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate)
}
// MARK: - Observer
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &playerItemContext {
if keyPath == #keyPath(AVPlayer.status) {
guard let playerStatus = AVPlayerItem.Status(rawValue: (change?[.newKey] as? Int ?? -1)) else { return }
if playerStatus == .readyToPlay {
self.updateNowPlaying()
let firstReady = self.status < 0
self.status = 0
if self.playWhenReady {
seek(playbackSession.currentTime)
self.playWhenReady = false
self.play()
} else if (firstReady) { // Only seek on first readyToPlay
seek(playbackSession.currentTime)
}
}
}
} else if context == &playerContext {
if context == &playerContext {
if keyPath == #keyPath(AVPlayer.rate) {
NSLog("TEST: playerContext observer player rate")
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
} else if keyPath == #keyPath(AVPlayer.currentItem) {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
NSLog("WARNING: Item ended")
}
} else {
+73 -36
View File
@@ -12,7 +12,44 @@ class PlayerHandler {
private static var session: PlaybackSession?
private static var timer: Timer?
private static var listeningTimePassedSinceLastSync = 0.0
private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? {
get {
return _remainingSleepTime
}
set(time) {
if time != nil && time! < 0 {
_remainingSleepTime = nil
} else {
_remainingSleepTime = time
}
if _remainingSleepTime == nil {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: _remainingSleepTime)
} else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: _remainingSleepTime)
}
}
}
private static var listeningTimePassedSinceLastSync: Double = 0.0
private static var lastSyncReport: PlaybackReport?
public static var paused: Bool {
get {
guard let player = player else {
return true
}
return player.rate == 0.0
}
set(paused) {
if paused {
self.player?.pause()
} else {
self.player?.play()
}
}
}
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
if player != nil {
@@ -20,16 +57,16 @@ class PlayerHandler {
player = nil
}
NowPlayingInfo.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
self.session = session
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
// DispatchQueue.main.sync {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
DispatchQueue.runOnMainQueue {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
// }
}
public static func stopPlayback() {
player?.destroy()
@@ -38,7 +75,7 @@ class PlayerHandler {
timer?.invalidate()
timer = nil
NowPlayingInfo.reset()
NowPlayingInfo.shared.reset()
}
public static func getCurrentTime() -> Double? {
@@ -47,45 +84,32 @@ class PlayerHandler {
public static func setPlaybackSpeed(speed: Float) {
self.player?.setPlaybackRate(speed)
}
public static func play() {
self.player?.play()
public static func getPlayMethod() -> Int? {
self.player?.getPlayMethod()
}
public static func pause() {
self.player?.play()
}
public static func playPause() {
if paused() {
self.player?.play()
} else {
self.player?.pause()
}
public static func getPlaybackSession() -> PlaybackSession? {
self.player?.getPlaybackSession()
}
public static func seekForward(amount: Double) {
if player == nil {
guard let player = player else {
return
}
let destinationTime = player!.getCurrentTime() + amount
player!.seek(destinationTime)
let destinationTime = player.getCurrentTime() + amount
player.seek(destinationTime, from: "handler")
}
public static func seekBackward(amount: Double) {
if player == nil {
guard let player = player else {
return
}
let destinationTime = player!.getCurrentTime() - amount
player!.seek(destinationTime)
let destinationTime = player.getCurrentTime() - amount
player.seek(destinationTime, from: "handler")
}
public static func seek(amount: Double) {
player?.seek(amount)
player?.seek(amount, from: "handler")
}
public static func paused() -> Bool {
player?.rate == 0.0
}
public static func getMetdata() -> [String: Any] {
DispatchQueue.main.async {
syncProgress()
@@ -94,29 +118,42 @@ class PlayerHandler {
return [
"duration": player?.getDuration() ?? 0,
"currentTime": player?.getCurrentTime() ?? 0,
"playerState": !paused(),
"playerState": !paused,
"currentRate": player?.rate ?? 0,
]
}
private static func tick() {
if !paused() {
if !paused {
listeningTimePassedSinceLastSync += 1
}
if listeningTimePassedSinceLastSync > 3 {
syncProgress()
}
if remainingSleepTime != nil {
if remainingSleepTime! == 0 {
paused = true
}
remainingSleepTime! -= 1
}
}
public static func syncProgress() {
if player == nil || session == nil {
if session == nil { return }
guard let player = player else { return }
let playerCurrentTime = player.getCurrentTime()
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
// No need to syncProgress
return
}
let report = PlaybackReport(currentTime: player!.getCurrentTime(), duration: player!.getDuration(), timeListened: listeningTimePassedSinceLastSync)
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = player!.getCurrentTime()
session!.currentTime = playerCurrentTime
listeningTimePassedSinceLastSync = 0
lastSyncReport = report
// TODO: check if online
NSLog("sending playback report")
+22 -2
View File
@@ -9,7 +9,20 @@ import Foundation
import Alamofire
class ApiClient {
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
if let data = data {
completion(UIImage(data:data))
}
}).resume()
}
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: String], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
@@ -25,6 +38,12 @@ class ApiClient {
}
}
public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
@@ -42,14 +61,15 @@ class ApiClient {
}
}
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: PlaybackSession) -> Void) {
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
var endpoint = "api/items/\(libraryItemId)/play"
if episodeId != nil {
endpoint += "/\(episodeId!)"
}
ApiClient.postResource(endpoint: endpoint, parameters: [
"forceTranscode": "true", // TODO: direct play
"forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer",
], decodable: PlaybackSession.self) { obj in
var session = obj
+22 -13
View File
@@ -10,16 +10,25 @@ import RealmSwift
class Database {
// All DB releated actions must be executed on "realm-queue"
public static let realmQueue = DispatchQueue(label: "realm-queue")
private static var instance: Realm = try! Realm(queue: realmQueue)
public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue")
public static var shared = {
realmQueue.sync {
return Database()
}
}()
private var instance: Realm
private init() {
self.instance = try! Realm(queue: Database.realmQueue)
}
public static func setServerConnectionConfig(config: ServerConnectionConfig) {
public func setServerConnectionConfig(config: ServerConnectionConfig) {
var refrence: ThreadSafeReference<ServerConnectionConfig>?
if config.realm != nil {
refrence = ThreadSafeReference(to: config)
}
realmQueue.sync {
Database.realmQueue.sync {
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
if config.index == 0 {
@@ -55,8 +64,8 @@ class Database {
setLastActiveConfigIndex(index: config.index)
}
}
public static func deleteServerConnectionConfig(id: String) {
realmQueue.sync {
public func deleteServerConnectionConfig(id: String) {
Database.realmQueue.sync {
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
do {
@@ -71,10 +80,10 @@ class Database {
}
}
}
public static func getServerConnectionConfigs() -> [ServerConnectionConfig] {
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
realmQueue.sync {
Database.realmQueue.sync {
let configs = instance.objects(ServerConnectionConfig.self)
refrences = configs.map { config in
return ThreadSafeReference(to: config)
@@ -94,12 +103,12 @@ class Database {
}
}
public static func setLastActiveConfigIndexToNil() {
realmQueue.sync {
public func setLastActiveConfigIndexToNil() {
Database.realmQueue.sync {
setLastActiveConfigIndex(index: nil)
}
}
public static func setLastActiveConfigIndex(index: Int?) {
public func setLastActiveConfigIndex(index: Int?) {
let existing = instance.objects(ServerConnectionConfigActiveIndex.self)
let obj = ServerConnectionConfigActiveIndex()
obj.index = index
@@ -114,8 +123,8 @@ class Database {
debugPrint(exception)
}
}
public static func getLastActiveConfigIndex() -> Int? {
return realmQueue.sync {
public func getLastActiveConfigIndex() -> Int? {
return Database.realmQueue.sync {
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
}
}
+11
View File
@@ -18,3 +18,14 @@ extension Encodable {
return dictionary
}
}
extension DispatchQueue {
static func runOnMainQueue(callback: @escaping (() -> Void)) {
if Thread.isMainThread {
callback()
} else {
DispatchQueue.main.sync {
callback()
}
}
}
}
+14 -22
View File
@@ -8,14 +8,6 @@
import Foundation
import MediaPlayer
func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
if let data = data {
completion(UIImage(data:data))
}
}).resume()
}
struct NowPlayingMetadata {
var id: String
var itemId: String
@@ -26,22 +18,22 @@ struct NowPlayingMetadata {
}
class NowPlayingInfo {
private static var nowPlayingInfo: [String: Any] = [:]
static var shared = {
return NowPlayingInfo()
}()
public static func setSessionMetadata(metadata: NowPlayingMetadata) {
private var nowPlayingInfo: [String: Any]
private init() {
self.nowPlayingInfo = [:]
}
public func setSessionMetadata(metadata: NowPlayingMetadata) {
setMetadata(artwork: nil, metadata: metadata)
/*
if !shouldFetchCover(id: metadata.id) || metadata.artworkUrl == nil {
return
}
*/
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
return
}
getData(from: url) { [self] image in
ApiClient.getData(from: url) { [self] image in
guard let downloadedImage = image else {
return
}
@@ -52,7 +44,7 @@ class NowPlayingInfo {
self.setMetadata(artwork: artwork, metadata: metadata)
}
}
public static func update(duration: Double, currentTime: Double, rate: Float) {
public func update(duration: Double, currentTime: Double, rate: Float) {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
@@ -60,12 +52,12 @@ class NowPlayingInfo {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
public static func reset() {
public func reset() {
nowPlayingInfo = [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
private static func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) {
private func setMetadata(artwork: MPMediaItemArtwork?, metadata: NowPlayingMetadata?) {
if metadata == nil {
return
}
@@ -84,7 +76,7 @@ class NowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
}
private static func shouldFetchCover(id: String) -> Bool {
private func shouldFetchCover(id: String) -> Bool {
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
}
}
+3
View File
@@ -10,4 +10,7 @@ import Foundation
enum PlayerEvents: String {
case update = "com.audiobookshelf.app.player.update"
case closed = "com.audiobookshelf.app.player.closed"
case sleepSet = "com.audiobookshelf.app.player.sleep.set"
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
case failed = "com.audiobookshelf.app.player.failed"
}
+2 -2
View File
@@ -16,9 +16,9 @@ class Store {
}
set(updated) {
if updated != nil {
Database.setServerConnectionConfig(config: updated!)
Database.shared.setServerConnectionConfig(config: updated!)
} else {
Database.setLastActiveConfigIndexToNil()
Database.shared.setLastActiveConfigIndexToNil()
}
Database.realmQueue.sync {
+1 -2
View File
@@ -30,8 +30,7 @@ export default {
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "0.9.42-beta",
"version": "0.9.44-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",
-16
View File
@@ -1,16 +0,0 @@
<template>
<div class="w-full h-full">
<p class="text-xl text-center py-8">Under Construction...</p>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+83 -13
View File
@@ -6,46 +6,53 @@
<covers-book-cover :library-item="libraryItem" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div>
<!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p>
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
</div>
<div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1>
<!-- <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> -->
<h3 v-if="seriesName" class="text-gray-300 text-sm leading-6">{{ seriesName }}</h3>
<p class="text-sm text-gray-400">by {{ author }}</p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1">
{{ $elapsedPretty(duration) }}
<span class="px-4">{{ $bytesPretty(size) }}</span>
<span v-if="!isLocal" class="px-4">{{ $bytesPretty(size) }}</span>
</p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1">{{ numTracks }} Tracks</p>
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div v-if="isLocal" class="flex mt-4 -mr-2">
<div v-if="isLocal" class="flex mt-4">
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play Local' }}</span>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play' }}</span>
</ui-btn>
<ui-btn v-if="showRead && isConnected" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4 -mr-2">
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play Local' : 'Play Stream' }}</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play' : 'Stream' }}</span>
</ui-btn>
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-btn v-if="user && showPlay && !isIos && !hasLocal" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<ui-btn v-if="user && showPlay && !isIos && !hasLocal" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mr-2" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
</ui-btn>
<ui-read-icon-btn v-if="!isPodcast" :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="flex items-center justify-center" @click="toggleFinished" />
</div>
</div>
</div>
@@ -102,6 +109,7 @@ export default {
data() {
return {
resettingProgress: false,
isProcessingReadUpdate: false,
showSelectLocalFolder: false
}
},
@@ -128,8 +136,14 @@ export default {
var podcastMedia = this.localLibraryItem.media
return podcastMedia ? podcastMedia.episodes || [] : []
},
isConnected() {
return this.$store.state.socketConnected
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
@@ -162,6 +176,10 @@ export default {
series() {
return this.mediaMetadata.series || []
},
seriesName() {
// For books only on toJSONExpanded
return this.mediaMetadata.seriesName || ''
},
duration() {
return this.media.duration
},
@@ -232,6 +250,9 @@ export default {
},
episodes() {
return this.media.episodes || []
},
isCasting() {
return this.$store.state.isCasting
}
},
methods: {
@@ -240,7 +261,12 @@ export default {
},
playClick() {
// Todo: Allow playing local or streaming
if (this.hasLocal) return this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id })
if (this.hasLocal && this.serverLibraryItemId && this.isCasting) {
// If casting and connected to server for local library item then send server library item id
this.$eventBus.$emit('play-item', { libraryItemId: this.serverLibraryItemId })
return
}
if (this.hasLocal) return this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id, serverLibraryItemId: this.serverLibraryItemId })
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
async clearProgressClick() {
@@ -351,15 +377,59 @@ export default {
console.log('New local library item', item.id)
this.$set(this.libraryItem, 'localLibraryItem', item)
}
},
async toggleFinished() {
this.isProcessingReadUpdate = true
if (this.isLocal) {
var isFinished = !this.userIsFinished
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) {
var errorMsg = payload ? payload.error : 'Unknown error'
this.$toast.error(errorMsg)
} else {
var localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
console.log('toggleFinished Check LMP', this.libraryItemId, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
}
this.isProcessingReadUpdate = false
} else {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
}
}
},
mounted() {
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.on('item_updated', this.itemUpdated)
this.$socket.on('item_updated', this.itemUpdated)
},
beforeDestroy() {
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.off('item_updated', this.itemUpdated)
this.$socket.off('item_updated', this.itemUpdated)
}
}
</script>
+14 -1
View File
@@ -76,7 +76,11 @@ export default {
},
methods: {
async runSearch(value) {
if (this.isFetching && this.lastSearch === value) return
this.lastSearch = value
this.$store.commit('globals/setLastSearch', value)
if (!this.lastSearch) {
this.bookResults = []
this.podcastResults = []
@@ -89,6 +93,10 @@ export default {
console.error('Search error', error)
return []
})
if (value !== this.lastSearch) {
console.log(`runSearch: New search was made for ${this.lastSearch} - results are from ${value}`)
return
}
console.log('RESULTS', results)
this.isFetching = false
@@ -113,7 +121,12 @@ export default {
}
},
mounted() {
this.$nextTick(this.setFocus())
if (this.$store.state.globals.lastSearch) {
this.search = this.$store.state.globals.lastSearch
this.runSearch(this.search)
} else {
this.$nextTick(this.setFocus())
}
}
}
</script>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="w-full h-full p-8">
<div class="flex items-center py-3" @click="toggleDisableAutoRewind">
<div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
</div>
<p class="pl-4">Disable Auto Rewind</p>
</div>
<div class="flex items-center py-3" @click="toggleJumpBackwards">
<div class="w-10 flex justify-center">
<span class="material-icons text-4xl">{{ currentJumpBackwardsTimeIcon }}</span>
</div>
<p class="pl-4">Jump backwards time</p>
</div>
<div class="flex items-center py-3" @click="toggleJumpForwards">
<div class="w-10 flex justify-center">
<span class="material-icons text-4xl">{{ currentJumpForwardsTimeIcon }}</span>
</div>
<p class="pl-4">Jump forwards time</p>
</div>
</div>
</template>
<script>
export default {
data() {
return {
settings: {
disableAutoRewind: false,
jumpForwardsTime: 10000,
jumpBackwardsTime: 10000
},
jumpForwardsItems: [
{
icon: 'forward_5',
value: 5000
},
{
icon: 'forward_10',
value: 10000
},
{
icon: 'forward_30',
value: 30000
}
],
jumpBackwardsItems: [
{
icon: 'replay_5',
value: 5000
},
{
icon: 'replay_10',
value: 10000
},
{
icon: 'replay_30',
value: 30000
}
]
}
},
computed: {
currentJumpForwardsTimeIcon() {
return this.jumpForwardsItems[this.currentJumpForwardsTimeIndex].icon
},
currentJumpForwardsTimeIndex() {
return this.jumpForwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpForwardsTime)
},
currentJumpBackwardsTimeIcon() {
return this.jumpBackwardsItems[this.currentJumpBackwardsTimeIndex].icon
},
currentJumpBackwardsTimeIndex() {
return this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
}
},
methods: {
toggleDisableAutoRewind() {
this.settings.disableAutoRewind = !this.settings.disableAutoRewind
this.saveSettings()
},
toggleJumpForwards() {
var next = (this.currentJumpForwardsTimeIndex + 1) % 3
this.settings.jumpForwardsTime = this.jumpForwardsItems[next].value
this.saveSettings()
},
toggleJumpBackwards() {
var next = (this.currentJumpBackwardsTimeIndex + 4) % 3
console.log('next', next)
if (next > 2) return
this.settings.jumpBackwardsTime = this.jumpBackwardsItems[next].value
this.saveSettings()
},
saveSettings() {
// TODO: Save settings
}
},
mounted() {
// TODO: Load settings
}
}
</script>
+2 -1
View File
@@ -199,7 +199,8 @@ class AbsDatabaseWeb extends WebPlugin {
return []
}
async updateLocalMediaProgressFinished({ localMediaProgressId, isFinished }) {
async updateLocalMediaProgressFinished(payload) {
// { localLibraryItemId, localEpisodeId, isFinished }
return null
}
}
-22
View File
@@ -6,28 +6,6 @@ const isWeb = Capacitor.getPlatform() == 'web'
class DbService {
constructor() { }
// Please dont use this, it is not implemented in ios (maybe key: primary value: any ?)
save(db, key, value) {
if (isWeb) return
return AbsDatabase.saveFromWebview({ db, key, value }).then(() => {
console.log('Saved data', db, key, JSON.stringify(value))
}).catch((error) => {
console.error('Failed to save data', error)
})
}
// Please dont use this, it is not implemented in ios
load(db, key) {
if (isWeb) return null
return AbsDatabase.loadFromWebview({ db, key }).then((data) => {
console.log('Loaded data', db, key, JSON.stringify(data))
return data
}).catch((error) => {
console.error('Failed to load', error)
return null
})
}
getDeviceData() {
return AbsDatabase.getDeviceData().then((data) => {
console.log('Loaded device data', JSON.stringify(data))
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name Source.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

+5 -1
View File
@@ -2,7 +2,8 @@ export const state = () => ({
itemDownloads: [],
bookshelfListView: false,
series: null,
localMediaProgress: []
localMediaProgress: [],
lastSearch: null
})
export const getters = {
@@ -82,5 +83,8 @@ export const mutations = {
},
removeLocalMediaProgress(state, id) {
state.localMediaProgress = state.localMediaProgress.filter(lmp => lmp.id != id)
},
setLastSearch(state, val) {
state.lastSearch = val
}
}
-3
View File
@@ -21,9 +21,6 @@ export const state = () => ({
})
export const getters = {
playerIsOpen: (state) => {
return state.streamAudiobook
},
getIsItemStreaming: state => libraryItemId => {
return state.playerLibraryItemId == libraryItemId
},